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:
parent
c192ac394d
commit
423844af3e
192
app.js
192
app.js
@ -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
|
||||||
|
|||||||
17
styles.css
17
styles.css
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user