Add visual month navigator with mini donut chart previews
Replace dropdown selector with a visual navigator featuring: - Left/right arrow buttons for month navigation - Clickable month buttons with mini donut chart previews - Russian month labels (e.g., "Январь'25") - Responsive layout for mobile screens - Arrows disable at navigation boundaries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a805914fe6
commit
9a0589d3f9
197
app.js
197
app.js
@ -871,6 +871,80 @@ function loadTinyColor() {
|
||||
});
|
||||
}
|
||||
|
||||
// Global state for month navigation
|
||||
let availableMonths = [];
|
||||
let currentMonthIndex = 0;
|
||||
let monthDataCache = {}; // Cache for month data previews
|
||||
|
||||
// Predefined colors for categories (same as in transformToSunburst)
|
||||
const categoryColors = [
|
||||
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
||||
'#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72',
|
||||
'#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787'
|
||||
];
|
||||
|
||||
// Fixed order for categories
|
||||
const categoryOrder = [
|
||||
'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи',
|
||||
'Здоровье', 'Логистика', 'Расходники', 'Красота'
|
||||
];
|
||||
|
||||
// Generate conic-gradient CSS for a month's category breakdown
|
||||
function generateMonthPreviewGradient(data) {
|
||||
// Group by category and sum amounts
|
||||
const categoryTotals = {};
|
||||
let total = 0;
|
||||
|
||||
data.forEach(item => {
|
||||
const category = item.category || '';
|
||||
const amount = Math.abs(parseFloat(item.amount_rub));
|
||||
if (!isNaN(amount) && category) {
|
||||
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
||||
total += amount;
|
||||
}
|
||||
});
|
||||
|
||||
if (total === 0) return 'conic-gradient(#eee 0deg 360deg)';
|
||||
|
||||
// Sort categories by predefined order
|
||||
const sortedCategories = Object.keys(categoryTotals).sort((a, b) => {
|
||||
const indexA = categoryOrder.indexOf(a);
|
||||
const indexB = categoryOrder.indexOf(b);
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return categoryTotals[b] - categoryTotals[a];
|
||||
});
|
||||
|
||||
// Build conic-gradient
|
||||
const gradientStops = [];
|
||||
let currentAngle = 0;
|
||||
|
||||
sortedCategories.forEach((category, index) => {
|
||||
const percentage = categoryTotals[category] / total;
|
||||
const angle = percentage * 360;
|
||||
const colorIndex = categoryOrder.indexOf(category);
|
||||
const color = colorIndex !== -1 ? categoryColors[colorIndex] : categoryColors[index % categoryColors.length];
|
||||
|
||||
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
|
||||
currentAngle += angle;
|
||||
});
|
||||
|
||||
return `conic-gradient(${gradientStops.join(', ')})`;
|
||||
}
|
||||
|
||||
// Format month for display: "2025-01" -> "Январь'25"
|
||||
function formatMonthLabel(dateStr) {
|
||||
const [year, month] = dateStr.split('-');
|
||||
const shortYear = year.slice(-2);
|
||||
const russianMonths = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
const monthName = russianMonths[parseInt(month) - 1];
|
||||
return `${monthName}'${shortYear}`;
|
||||
}
|
||||
|
||||
// Load all available month files
|
||||
async function loadAvailableMonths() {
|
||||
// Load TinyColor first
|
||||
@ -878,39 +952,92 @@ async function loadAvailableMonths() {
|
||||
|
||||
// Fetch available months from server
|
||||
const response = await fetch('/api/months');
|
||||
const months = await response.json();
|
||||
availableMonths = await response.json();
|
||||
|
||||
if (months.length === 0) {
|
||||
if (availableMonths.length === 0) {
|
||||
console.error('No month data files found');
|
||||
return;
|
||||
}
|
||||
|
||||
const select = document.getElementById('month-select');
|
||||
const monthList = document.getElementById('month-list');
|
||||
|
||||
// Clear existing options
|
||||
select.innerHTML = '';
|
||||
monthList.innerHTML = '';
|
||||
|
||||
months.forEach(month => {
|
||||
// Populate hidden select (for compatibility with renderChart)
|
||||
availableMonths.forEach(month => {
|
||||
const option = document.createElement('option');
|
||||
option.value = month;
|
||||
option.textContent = month;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
// Load the most recent month by default
|
||||
const latestMonth = months[months.length - 1];
|
||||
|
||||
// Set the select value to match BEFORE loading data
|
||||
select.value = latestMonth;
|
||||
|
||||
// Now load the data for the selected month
|
||||
const data = await parseCSV(`altcats-${latestMonth}.csv`);
|
||||
renderChart(data);
|
||||
|
||||
// Add event listener for month selection
|
||||
select.addEventListener('change', async (e) => {
|
||||
const month = e.target.value;
|
||||
// Load data for all months and create buttons with previews
|
||||
await Promise.all(availableMonths.map(async (month, index) => {
|
||||
const data = await parseCSV(`altcats-${month}.csv`);
|
||||
monthDataCache[month] = data;
|
||||
}));
|
||||
|
||||
// Create month buttons with previews
|
||||
availableMonths.forEach((month, index) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'month-btn';
|
||||
btn.dataset.month = month;
|
||||
btn.dataset.index = index;
|
||||
|
||||
// Create preview circle
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'month-preview';
|
||||
preview.style.background = generateMonthPreviewGradient(monthDataCache[month]);
|
||||
|
||||
// Create label
|
||||
const label = document.createElement('span');
|
||||
label.className = 'month-label';
|
||||
label.textContent = formatMonthLabel(month);
|
||||
|
||||
btn.appendChild(preview);
|
||||
btn.appendChild(label);
|
||||
btn.addEventListener('click', () => selectMonth(index));
|
||||
monthList.appendChild(btn);
|
||||
});
|
||||
|
||||
// Load the most recent month by default
|
||||
currentMonthIndex = availableMonths.length - 1;
|
||||
await selectMonth(currentMonthIndex);
|
||||
|
||||
// Set up arrow button handlers
|
||||
document.getElementById('prev-month').addEventListener('click', () => {
|
||||
if (currentMonthIndex > 0) {
|
||||
selectMonth(currentMonthIndex - 1);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('next-month').addEventListener('click', () => {
|
||||
if (currentMonthIndex < availableMonths.length - 1) {
|
||||
selectMonth(currentMonthIndex + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select and load a specific month
|
||||
async function selectMonth(index) {
|
||||
currentMonthIndex = index;
|
||||
const month = availableMonths[index];
|
||||
|
||||
// Update hidden select for compatibility
|
||||
const select = document.getElementById('month-select');
|
||||
select.value = month;
|
||||
|
||||
// Update month button active states
|
||||
updateMonthNavigator();
|
||||
|
||||
// Load and render data
|
||||
const data = await parseCSV(`altcats-${month}.csv`);
|
||||
|
||||
// Check if chart already has data (for animation)
|
||||
if (option && option.series && option.series.data) {
|
||||
const sunburstData = transformToSunburst(data);
|
||||
|
||||
// Update only the series data and preserve layout
|
||||
@ -918,9 +1045,9 @@ async function loadAvailableMonths() {
|
||||
const newData = sunburstData.data;
|
||||
|
||||
// Map old values to new data to preserve positions
|
||||
newData.forEach((newItem, index) => {
|
||||
if (oldData[index]) {
|
||||
newItem.layoutId = oldData[index].name; // Use name as layout identifier
|
||||
newData.forEach((newItem, idx) => {
|
||||
if (oldData[idx]) {
|
||||
newItem.layoutId = oldData[idx].name;
|
||||
}
|
||||
});
|
||||
|
||||
@ -938,7 +1065,8 @@ async function loadAvailableMonths() {
|
||||
layoutAnimation: true,
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicInOut'
|
||||
}]
|
||||
}],
|
||||
graphic: option.graphic
|
||||
}, {
|
||||
lazyUpdate: false,
|
||||
silent: false
|
||||
@ -946,7 +1074,36 @@ async function loadAvailableMonths() {
|
||||
|
||||
// Update hover events
|
||||
setupHoverEvents(sunburstData);
|
||||
} else {
|
||||
// Initial render
|
||||
renderChart(data);
|
||||
}
|
||||
|
||||
// Scroll selected month button into view
|
||||
const activeBtn = document.querySelector('.month-btn.active');
|
||||
if (activeBtn) {
|
||||
activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update month navigator UI state
|
||||
function updateMonthNavigator() {
|
||||
// Update button active states
|
||||
const buttons = document.querySelectorAll('.month-btn');
|
||||
buttons.forEach((btn, index) => {
|
||||
if (index === currentMonthIndex) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update arrow disabled states
|
||||
const prevBtn = document.getElementById('prev-month');
|
||||
const nextBtn = document.getElementById('next-month');
|
||||
|
||||
prevBtn.disabled = currentMonthIndex === 0;
|
||||
nextBtn.disabled = currentMonthIndex === availableMonths.length - 1;
|
||||
}
|
||||
|
||||
// Initialize the visualization
|
||||
|
||||
@ -12,10 +12,12 @@
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Семейные траты за месяц</h1>
|
||||
<div class="month-selector">
|
||||
<label for="month-select">Месяц:</label>
|
||||
<select id="month-select"></select>
|
||||
<div class="month-navigator">
|
||||
<button class="nav-arrow nav-prev" id="prev-month">‹</button>
|
||||
<div class="month-list" id="month-list"></div>
|
||||
<button class="nav-arrow nav-next" id="next-month">›</button>
|
||||
</div>
|
||||
<select id="month-select" style="display: none;"></select>
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div id="chart-container"></div>
|
||||
|
||||
135
styles.css
135
styles.css
@ -23,16 +23,108 @@ body {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.month-selector {
|
||||
/* Month Navigator */
|
||||
.month-navigator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
#month-select {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
.nav-arrow {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-arrow:hover:not(:disabled) {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.nav-arrow:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.month-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.month-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.month-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.month-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.month-btn.active {
|
||||
background-color: #5470c6;
|
||||
border-color: #5470c6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.month-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #eee;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.month-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 35%;
|
||||
height: 35%;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.month-btn.active .month-preview {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.month-btn.active .month-preview::after {
|
||||
background: #5470c6;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
@ -163,6 +255,17 @@ body {
|
||||
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.month-navigator {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -187,3 +290,25 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.month-btn {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.month-preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user