Add multi-month selection with Shift+click

Hold Shift and click months to select multiple, showing aggregated
spending data. Normal click resets to single month selection.
Cannot deselect the last remaining month.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-02-03 15:36:16 +03:00
parent c192ac394d
commit 423844af3e
2 changed files with 197 additions and 12 deletions

192
app.js
View File

@ -73,11 +73,14 @@ function resetHistory() {
// Navigate to a specific history state // Navigate to a specific history state
function navigateToHistoryState(state) { function navigateToHistoryState(state) {
const russianMonth = getRussianMonthName(document.getElementById('month-select').value); // Use multi-month label if multiple months selected, otherwise single month name
const monthLabel = selectedMonthIndices.size > 1
? generateSelectedMonthsLabel()
: getRussianMonthName(document.getElementById('month-select').value);
option.series.data = state.data; option.series.data = state.data;
myChart.setOption(option, { replaceMerge: ['series'] }); myChart.setOption(option, { replaceMerge: ['series'] });
updateCenterLabel(russianMonth, state.total, state.contextName); updateCenterLabel(monthLabel, state.total, state.contextName);
setupHoverEvents({ total: state.total, data: state.data }, state.contextName); setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
// Restore drill path and update mini-charts // Restore drill path and update mini-charts
@ -470,9 +473,11 @@ function renderChart(data) {
resetHistory(); resetHistory();
saveToHistory(sunburstData.data, sunburstData.total, null, true, []); saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
// Get the currently selected month // Get the currently selected month label
const selectedMonth = document.getElementById('month-select').value; const selectedMonth = document.getElementById('month-select').value;
const russianMonth = getRussianMonthName(selectedMonth); const russianMonth = selectedMonthIndices.size > 1
? generateSelectedMonthsLabel()
: getRussianMonthName(selectedMonth);
// Calculate the correct center position first // Calculate the correct center position first
const screenWidth = window.innerWidth; const screenWidth = window.innerWidth;
@ -940,9 +945,11 @@ function renderChart(data) {
option.series.data = newData; option.series.data = newData;
// Update the center text to show the drilled-down category // Update the center text to show the drilled-down category
const russianMonth = getRussianMonthName(document.getElementById('month-select').value); const monthLabel = selectedMonthIndices.size > 1
? generateSelectedMonthsLabel()
: getRussianMonthName(document.getElementById('month-select').value);
myChart.setOption(option, { replaceMerge: ['series'] }); myChart.setOption(option, { replaceMerge: ['series'] });
updateCenterLabel(russianMonth, params.value, params.name); updateCenterLabel(monthLabel, params.value, params.name);
// Update hover events with the new data structure, passing the drilled-down name // Update hover events with the new data structure, passing the drilled-down name
setupHoverEvents({ total: params.value, data: newData }, params.name); setupHoverEvents({ total: params.value, data: newData }, params.name);
@ -1045,6 +1052,7 @@ function loadTinyColor() {
// Global state for month navigation // Global state for month navigation
let availableMonths = []; let availableMonths = [];
let currentMonthIndex = 0; let currentMonthIndex = 0;
let selectedMonthIndices = new Set(); // Track all selected months for multi-selection
let monthDataCache = {}; // Cache for month data previews let monthDataCache = {}; // Cache for month data previews
// Predefined colors for categories (same as in transformToSunburst) // Predefined colors for categories (same as in transformToSunburst)
@ -1429,24 +1437,42 @@ async function loadAvailableMonths() {
btn.appendChild(preview); btn.appendChild(preview);
btn.appendChild(label); btn.appendChild(label);
btn.addEventListener('click', () => selectMonth(index)); btn.addEventListener('click', (event) => {
if (event.shiftKey) {
toggleMonthSelection(index);
} else {
selectSingleMonth(index);
}
});
monthList.appendChild(btn); monthList.appendChild(btn);
}); });
// Load the most recent month by default // Load the most recent month by default
currentMonthIndex = availableMonths.length - 1; currentMonthIndex = availableMonths.length - 1;
selectedMonthIndices.clear();
selectedMonthIndices.add(currentMonthIndex);
await selectMonth(currentMonthIndex); await selectMonth(currentMonthIndex);
// Set up arrow button handlers // Set up arrow button handlers
document.getElementById('prev-month').addEventListener('click', () => { document.getElementById('prev-month').addEventListener('click', (event) => {
if (currentMonthIndex > 0) { if (currentMonthIndex > 0) {
selectMonth(currentMonthIndex - 1); if (event.shiftKey) {
// Add previous month to selection
toggleMonthSelection(currentMonthIndex - 1);
} else {
selectSingleMonth(currentMonthIndex - 1);
}
} }
}); });
document.getElementById('next-month').addEventListener('click', () => { document.getElementById('next-month').addEventListener('click', (event) => {
if (currentMonthIndex < availableMonths.length - 1) { if (currentMonthIndex < availableMonths.length - 1) {
selectMonth(currentMonthIndex + 1); if (event.shiftKey) {
// Add next month to selection
toggleMonthSelection(currentMonthIndex + 1);
} else {
selectSingleMonth(currentMonthIndex + 1);
}
} }
}); });
@ -1656,6 +1682,136 @@ function navigateToPath(sunburstData, path) {
}; };
} }
// Toggle a month in/out of multi-selection (Shift+click behavior)
async function toggleMonthSelection(index) {
if (selectedMonthIndices.has(index)) {
// Don't allow deselecting the last month
if (selectedMonthIndices.size > 1) {
selectedMonthIndices.delete(index);
// If we removed the current navigation index, update it to another selected month
if (index === currentMonthIndex) {
currentMonthIndex = Math.max(...selectedMonthIndices);
}
}
} else {
selectedMonthIndices.add(index);
currentMonthIndex = index; // Update navigation index to the newly added month
}
await renderSelectedMonths();
}
// Select a single month (normal click behavior)
async function selectSingleMonth(index) {
selectedMonthIndices.clear();
selectedMonthIndices.add(index);
currentMonthIndex = index;
await selectMonth(index);
}
// Merge transaction data from multiple months
function mergeMonthsData(monthIndices) {
const allTransactions = [];
for (const index of monthIndices) {
const month = availableMonths[index];
const data = monthDataCache[month];
if (data) {
allTransactions.push(...data);
}
}
return allTransactions;
}
// Generate label for selected months
function generateSelectedMonthsLabel() {
if (selectedMonthIndices.size === 1) {
const index = [...selectedMonthIndices][0];
return getRussianMonthName(availableMonths[index]);
}
const sortedIndices = [...selectedMonthIndices].sort((a, b) => a - b);
// Check if consecutive
let isConsecutive = true;
for (let i = 1; i < sortedIndices.length; i++) {
if (sortedIndices[i] !== sortedIndices[i - 1] + 1) {
isConsecutive = false;
break;
}
}
if (isConsecutive && sortedIndices.length > 1) {
// Range: "Январь - Март"
const firstMonth = getRussianMonthName(availableMonths[sortedIndices[0]]);
const lastMonth = getRussianMonthName(availableMonths[sortedIndices[sortedIndices.length - 1]]);
return `${firstMonth} ${lastMonth}`;
} else {
// Non-consecutive: "3 месяца"
const count = sortedIndices.length;
// Russian plural rules for "месяц"
if (count === 1) return '1 месяц';
if (count >= 2 && count <= 4) return `${count} месяца`;
return `${count} месяцев`;
}
}
// Render chart with data from all selected months
async function renderSelectedMonths() {
// Merge data from all selected months
const mergedData = mergeMonthsData(selectedMonthIndices);
// Update UI
updateMonthNavigator();
// Update hidden select for compatibility (use first selected month)
const sortedIndices = [...selectedMonthIndices].sort((a, b) => a - b);
const select = document.getElementById('month-select');
select.value = availableMonths[sortedIndices[0]];
// Generate month label
const monthLabel = generateSelectedMonthsLabel();
// Transform merged data to sunburst
const sunburstData = transformToSunburst(mergedData);
// Update the module-level original data for center-click reset
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
// Reset history for the new selection
resetHistory();
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
// Update the chart
if (option && option.series && option.series.data) {
option.series.data = sunburstData.data;
myChart.setOption({
series: [{
type: 'sunburst',
data: sunburstData.data,
layoutAnimation: true,
animationDuration: 500,
animationEasing: 'cubicInOut'
}]
}, {
lazyUpdate: false,
silent: false
});
updateCenterLabel(monthLabel, sunburstData.total, null);
setupHoverEvents(sunburstData, null);
updateAllMonthPreviews();
} else {
// Initial render
renderChart(mergedData);
}
// Scroll to show the current navigation month
const activeBtn = document.querySelector('.month-btn.primary') || document.querySelector('.month-btn.active');
if (activeBtn) {
activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
// Select and load a specific month // Select and load a specific month
async function selectMonth(index) { async function selectMonth(index) {
const month = availableMonths[index]; const month = availableMonths[index];
@ -1761,12 +1917,24 @@ async function selectMonth(index) {
function updateMonthNavigator() { function updateMonthNavigator() {
// Update button active states // Update button active states
const buttons = document.querySelectorAll('.month-btn'); const buttons = document.querySelectorAll('.month-btn');
const isMultiSelect = selectedMonthIndices.size > 1;
buttons.forEach((btn, index) => { buttons.forEach((btn, index) => {
if (index === currentMonthIndex) { const isSelected = selectedMonthIndices.has(index);
const isPrimary = index === currentMonthIndex;
if (isSelected) {
btn.classList.add('active'); btn.classList.add('active');
} else { } else {
btn.classList.remove('active'); btn.classList.remove('active');
} }
// Primary class indicates current navigation position in multi-select
if (isMultiSelect && isPrimary) {
btn.classList.add('primary');
} else {
btn.classList.remove('primary');
}
}); });
// Update arrow disabled states // Update arrow disabled states

View File

@ -95,6 +95,23 @@ body {
color: white; color: white;
} }
/* Primary indicator for current navigation position in multi-select */
.month-btn.active.primary {
position: relative;
}
.month-btn.active.primary::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 6px;
background: white;
border-radius: 50%;
}
.month-preview { .month-preview {
width: 40px; width: 40px;
height: 40px; height: 40px;