On touch devices, tapping an eye button in the details panel would trigger chart mouseout → showDefaultView() which rebuilt the DOM before the click event fired. Added pointerdown/pointerup guards on the details box to prevent the race condition. Also added @media (pointer: coarse) to always show eye buttons on touch devices regardless of viewport width. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3705 lines
146 KiB
JavaScript
3705 lines
146 KiB
JavaScript
// Format RUB amount: no decimals, narrow no-break space as thousands separator
|
||
function formatRUB(amount) {
|
||
return Math.round(amount)
|
||
.toLocaleString('ru-RU')
|
||
.replace(/\u00a0/g, '\u202F');
|
||
}
|
||
|
||
// Update HTML center label (month in gray, category optional, amount in black)
|
||
function updateCenterLabel(month, amount, category = null) {
|
||
const labelEl = document.getElementById('center-label');
|
||
if (!labelEl) return;
|
||
|
||
labelEl.querySelector('.center-month').textContent = month;
|
||
labelEl.querySelector('.center-category').textContent = category || '';
|
||
labelEl.querySelector('.center-amount-num').textContent = formatRUB(amount);
|
||
labelEl.querySelector('.center-rub').textContent = '\u202F₽';
|
||
|
||
// Show/hide home icon based on drill-down state
|
||
updateDrilledDownState();
|
||
}
|
||
|
||
// Update drilled-down visual state (home icon visibility)
|
||
function updateDrilledDownState() {
|
||
const labelEl = document.getElementById('center-label');
|
||
if (!labelEl) return;
|
||
|
||
if (historyIndex > 0) {
|
||
labelEl.classList.add('drilled-down');
|
||
} else {
|
||
labelEl.classList.remove('drilled-down');
|
||
}
|
||
}
|
||
|
||
// Initialize the chart
|
||
const chartDom = document.getElementById('chart-container');
|
||
const myChart = echarts.init(chartDom);
|
||
let option;
|
||
let originalSunburstData = null; // Stores the original data for the current month (for reset on center click)
|
||
let showDefaultView = null; // Reference to reset details panel to default view
|
||
let currentView = 'month'; // 'month' | 'timeline'
|
||
|
||
// Drill-down history for back/forward navigation
|
||
let drillDownHistory = [];
|
||
let historyIndex = -1;
|
||
let currentDrillPath = []; // Tracks drill-down hierarchy: ["Еда", "Рестораны", ...]
|
||
|
||
// Save current chart state to history
|
||
function saveToHistory(data, total, contextName, isInitial = false, path = []) {
|
||
// Remove any forward history when drilling down from middle of history
|
||
if (historyIndex < drillDownHistory.length - 1) {
|
||
drillDownHistory = drillDownHistory.slice(0, historyIndex + 1);
|
||
}
|
||
drillDownHistory.push({ data, total, contextName, path: [...path] });
|
||
historyIndex = drillDownHistory.length - 1;
|
||
|
||
// Update current drill path
|
||
currentDrillPath = [...path];
|
||
|
||
// Push to browser history (use replaceState for initial state)
|
||
const state = { drillDown: historyIndex };
|
||
if (isInitial) {
|
||
history.replaceState(state, '');
|
||
} else {
|
||
history.pushState(state, '');
|
||
}
|
||
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Reset history (called when changing months)
|
||
function resetHistory() {
|
||
drillDownHistory = [];
|
||
historyIndex = -1;
|
||
currentDrillPath = [];
|
||
}
|
||
|
||
// Navigate to a specific history state
|
||
function navigateToHistoryState(state) {
|
||
// 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;
|
||
|
||
myChart.setOption(option, { replaceMerge: ['series'] });
|
||
updateCenterLabel(monthLabel, state.total, state.contextName);
|
||
setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
|
||
|
||
// Restore drill path and update mini-charts
|
||
currentDrillPath = state.path ? [...state.path] : [];
|
||
updateAllMonthPreviews();
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Go back in drill-down history
|
||
function navigateBack() {
|
||
if (historyIndex > 0) {
|
||
historyIndex--;
|
||
navigateToHistoryState(drillDownHistory[historyIndex]);
|
||
}
|
||
}
|
||
|
||
// Go forward in drill-down history
|
||
function navigateForward() {
|
||
if (historyIndex < drillDownHistory.length - 1) {
|
||
historyIndex++;
|
||
navigateToHistoryState(drillDownHistory[historyIndex]);
|
||
}
|
||
}
|
||
|
||
// Go directly to root level
|
||
function goToRoot() {
|
||
if (drillDownHistory.length > 0 && historyIndex > 0) {
|
||
historyIndex = 0;
|
||
navigateToHistoryState(drillDownHistory[0]);
|
||
// Update browser history to reflect root state
|
||
history.replaceState({ drillDown: 0 }, '');
|
||
}
|
||
}
|
||
|
||
// Listen for browser back/forward via popstate
|
||
window.addEventListener('popstate', function(e) {
|
||
// If a modal is open, close it and push state back to prevent navigation
|
||
const rowDetailModal = document.getElementById('row-detail-modal');
|
||
const transactionModal = document.getElementById('transaction-modal');
|
||
|
||
if (rowDetailModal && rowDetailModal.style.display !== 'none') {
|
||
closeRowDetailModal();
|
||
// Push state back to cancel the back navigation
|
||
history.pushState(e.state, '');
|
||
return;
|
||
}
|
||
|
||
if (transactionModal && transactionModal.style.display !== 'none') {
|
||
closeTransactionModal();
|
||
// Push state back to cancel the back navigation
|
||
history.pushState(e.state, '');
|
||
return;
|
||
}
|
||
|
||
// No modal open - handle navigation
|
||
if (e.state && e.state.timelineDrill !== undefined) {
|
||
// Timeline drill-down navigation (possibly with legend selection)
|
||
if (currentView !== 'timeline') {
|
||
currentView = 'timeline';
|
||
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.view === 'timeline');
|
||
});
|
||
document.querySelector('.container').classList.add('timeline-mode');
|
||
myChart.resize();
|
||
}
|
||
renderTimelineChart(e.state.timelineDrill, 'none', e.state.legendSelected || null);
|
||
return;
|
||
}
|
||
|
||
if (e.state && e.state.monthView) {
|
||
// Month view navigation (from view switch)
|
||
if (currentView !== 'month') {
|
||
switchView('month');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.state && e.state.drillDown !== undefined) {
|
||
// Donut drill-down navigation
|
||
if (currentView !== 'month') {
|
||
// Switch from timeline to month view, then re-render donut from scratch
|
||
switchView('month');
|
||
return;
|
||
}
|
||
const stateIndex = e.state.drillDown;
|
||
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
|
||
historyIndex = stateIndex;
|
||
navigateToHistoryState(drillDownHistory[historyIndex]);
|
||
}
|
||
} else {
|
||
// No state or initial state - go to root
|
||
if (drillDownHistory.length > 0) {
|
||
historyIndex = 0;
|
||
navigateToHistoryState(drillDownHistory[0]);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Function to parse CSV data
|
||
async function parseCSV(file) {
|
||
const response = await fetch(file);
|
||
const data = await response.text();
|
||
|
||
// Split the CSV into rows
|
||
const rows = data.split('\n');
|
||
|
||
// Extract headers and remove quotes
|
||
const headers = rows[0].split(',').map(header => header.replace(/"/g, ''));
|
||
|
||
// Parse the data rows
|
||
const result = [];
|
||
for (let i = 1; i < rows.length; i++) {
|
||
if (!rows[i].trim()) continue;
|
||
|
||
// Handle commas within quoted fields
|
||
const row = [];
|
||
let inQuote = false;
|
||
let currentValue = '';
|
||
|
||
for (let j = 0; j < rows[i].length; j++) {
|
||
const char = rows[i][j];
|
||
|
||
if (char === '"') {
|
||
inQuote = !inQuote;
|
||
} else if (char === ',' && !inQuote) {
|
||
row.push(currentValue.replace(/"/g, ''));
|
||
currentValue = '';
|
||
} else {
|
||
currentValue += char;
|
||
}
|
||
}
|
||
|
||
// Push the last value
|
||
row.push(currentValue.replace(/"/g, ''));
|
||
|
||
// Create an object from headers and row values
|
||
const obj = {};
|
||
for (let j = 0; j < headers.length; j++) {
|
||
obj[headers[j]] = row[j];
|
||
}
|
||
|
||
result.push(obj);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// Function to transform data into a sunburst format
|
||
function transformToSunburst(data) {
|
||
// Calculate total spending
|
||
let totalSpending = 0;
|
||
|
||
// Group by categories
|
||
const categories = [];
|
||
const categoryMap = {};
|
||
|
||
// Store raw transactions for each microcategory to display in details
|
||
const transactionMap = {};
|
||
|
||
// Predefined colors for categories
|
||
const colors = [
|
||
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
||
'#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72',
|
||
'#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787'
|
||
];
|
||
|
||
// Debug counter
|
||
let lekarstvaCount = 0;
|
||
let emptyMicroCount = 0;
|
||
|
||
data.forEach(item => {
|
||
const category = item.category || '';
|
||
const subcategory = item.subcategory || '';
|
||
// Get original microcategory value - important for transaction filtering later
|
||
const originalMicrocategory = item.microcategory || '';
|
||
const amount = Math.abs(parseFloat(item.amount_rub));
|
||
|
||
// For display in the chart, we might use simple_name for transactions >=1000RUB
|
||
let displayMicrocategory = originalMicrocategory;
|
||
if (displayMicrocategory === '' && amount >= 1000 && item.simple_name) {
|
||
displayMicrocategory = item.simple_name;
|
||
}
|
||
|
||
// Debug Лекарства category
|
||
/*if (subcategory === "Лекарства") {
|
||
lekarstvaCount++;
|
||
console.log(`Transaction in Лекарства: ${item.simple_name}, originalMicro="${originalMicrocategory}", displayMicro="${displayMicrocategory}", amount=${amount}`);
|
||
if (originalMicrocategory === '') {
|
||
emptyMicroCount++;
|
||
}
|
||
}*/
|
||
|
||
const transactionKey = `${category}|${subcategory}|${displayMicrocategory}`;
|
||
|
||
if (!isNaN(amount)) {
|
||
totalSpending += amount;
|
||
|
||
// Create transaction object and include displayMicrocategory property
|
||
const transactionObj = {
|
||
name: item.simple_name || 'Transaction',
|
||
value: amount,
|
||
date: item.date,
|
||
microCategory: originalMicrocategory, // Store ORIGINAL value
|
||
hasNoMicroCategory: originalMicrocategory === '', // Flag for easy filtering (legacy)
|
||
displayMicrocategory: displayMicrocategory,
|
||
originalRow: item // Store full CSV row for modal display
|
||
};
|
||
|
||
// Save transaction data for detail box with ORIGINAL microcategory
|
||
if (!transactionMap[transactionKey]) {
|
||
transactionMap[transactionKey] = [];
|
||
}
|
||
transactionMap[transactionKey].push(transactionObj);
|
||
|
||
if (!categoryMap[category] && category !== '') {
|
||
categoryMap[category] = {
|
||
name: category,
|
||
value: 0,
|
||
children: {},
|
||
itemStyle: {},
|
||
transactions: [] // Will be populated with ALL transactions
|
||
};
|
||
categories.push(categoryMap[category]);
|
||
}
|
||
|
||
if (category !== '') {
|
||
if (!categoryMap[category].children[subcategory] && subcategory !== '') {
|
||
categoryMap[category].children[subcategory] = {
|
||
name: subcategory,
|
||
value: 0,
|
||
children: {},
|
||
itemStyle: {},
|
||
transactions: [] // Will be populated with ALL transactions
|
||
};
|
||
}
|
||
|
||
// Add transaction to category directly
|
||
if (categoryMap[category].transactions) {
|
||
categoryMap[category].transactions.push(transactionObj);
|
||
}
|
||
|
||
if (subcategory !== '') {
|
||
// Add transaction to subcategory directly
|
||
if (categoryMap[category].children[subcategory].transactions) {
|
||
categoryMap[category].children[subcategory].transactions.push(transactionObj);
|
||
}
|
||
|
||
if (!categoryMap[category].children[subcategory].children[displayMicrocategory] && displayMicrocategory !== '') {
|
||
categoryMap[category].children[subcategory].children[displayMicrocategory] = {
|
||
name: displayMicrocategory,
|
||
value: 0,
|
||
itemStyle: {},
|
||
transactions: []
|
||
};
|
||
}
|
||
|
||
// Add transaction to microcategory if there is one
|
||
if (displayMicrocategory !== '' &&
|
||
categoryMap[category].children[subcategory].children[displayMicrocategory].transactions) {
|
||
categoryMap[category].children[subcategory].children[displayMicrocategory].transactions.push(transactionObj);
|
||
}
|
||
|
||
categoryMap[category].value += amount;
|
||
categoryMap[category].children[subcategory].value += amount;
|
||
|
||
if (displayMicrocategory !== '') {
|
||
categoryMap[category].children[subcategory].children[displayMicrocategory].value += amount;
|
||
}
|
||
} else {
|
||
categoryMap[category].value += amount;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log(`Found ${lekarstvaCount} transactions in Лекарства category, ${emptyMicroCount} with empty microcategory`);
|
||
|
||
// Define fixed order for top categories
|
||
const categoryOrder = [
|
||
'Квартира',
|
||
'Еда',
|
||
'Технологии',
|
||
'Развлечения',
|
||
'Семьи',
|
||
'Здоровье',
|
||
'Логистика',
|
||
'Расходники',
|
||
'Красота'
|
||
];
|
||
|
||
// Sort categories by the predefined order
|
||
categories.sort((a, b) => {
|
||
const indexA = categoryOrder.indexOf(a.name);
|
||
const indexB = categoryOrder.indexOf(b.name);
|
||
|
||
// If both categories are in our predefined list, sort by that order
|
||
if (indexA !== -1 && indexB !== -1) {
|
||
return indexA - indexB;
|
||
}
|
||
// If only a is in the list, it comes first
|
||
else if (indexA !== -1) {
|
||
return -1;
|
||
}
|
||
// If only b is in the list, it comes first
|
||
else if (indexB !== -1) {
|
||
return 1;
|
||
}
|
||
// For categories not in our list, sort by value (largest to smallest)
|
||
else {
|
||
return b.value - a.value;
|
||
}
|
||
});
|
||
|
||
// Convert the map to an array structure for ECharts
|
||
const result = [];
|
||
|
||
// Assign colors to categories
|
||
categories.forEach((category, index) => {
|
||
const colorIndex = index % colors.length;
|
||
const baseColor = colors[colorIndex];
|
||
const categoryNode = {
|
||
name: category.name,
|
||
value: category.value,
|
||
children: [],
|
||
itemStyle: {
|
||
color: baseColor
|
||
},
|
||
transactions: category.transactions
|
||
};
|
||
|
||
// Get subcategories and sort by value
|
||
const subcategories = [];
|
||
for (const subcatKey in category.children) {
|
||
subcategories.push(category.children[subcatKey]);
|
||
}
|
||
subcategories.sort((a, b) => b.value - a.value);
|
||
|
||
// Generate color variations for subcategories based on their size
|
||
const subcatColors = generateColorGradient(baseColor, subcategories.length || 1);
|
||
|
||
// Process each subcategory
|
||
subcategories.forEach((subcategory, subIndex) => {
|
||
// Adjust subcategory color based on its relative size within category
|
||
const subcatColor = subcatColors[subIndex];
|
||
|
||
const subcategoryNode = {
|
||
name: subcategory.name,
|
||
value: subcategory.value,
|
||
children: [],
|
||
itemStyle: {
|
||
color: subcatColor
|
||
},
|
||
transactions: subcategory.transactions
|
||
};
|
||
|
||
// Get microcategories and sort by value
|
||
const microcategories = [];
|
||
for (const microKey in subcategory.children) {
|
||
microcategories.push(subcategory.children[microKey]);
|
||
}
|
||
microcategories.sort((a, b) => b.value - a.value);
|
||
|
||
// Generate color variations for microcategories based on their size
|
||
const microColors = generateColorGradient(subcatColor, microcategories.length || 1);
|
||
|
||
// Add microcategories to subcategory
|
||
microcategories.forEach((micro, microIndex) => {
|
||
subcategoryNode.children.push({
|
||
name: micro.name,
|
||
value: micro.value,
|
||
itemStyle: {
|
||
color: microColors[microIndex]
|
||
},
|
||
transactions: micro.transactions
|
||
});
|
||
});
|
||
|
||
if (subcategoryNode.children.length > 0) {
|
||
categoryNode.children.push(subcategoryNode);
|
||
} else {
|
||
categoryNode.children.push({
|
||
name: subcategory.name,
|
||
value: subcategory.value,
|
||
itemStyle: {
|
||
color: subcatColor
|
||
},
|
||
transactions: subcategory.transactions
|
||
});
|
||
}
|
||
});
|
||
|
||
result.push(categoryNode);
|
||
});
|
||
|
||
return {
|
||
total: totalSpending,
|
||
data: result
|
||
};
|
||
}
|
||
|
||
// Build ECharts stacked bar option for timeline view
|
||
// drillPath: array like [] (categories), ['Cat'] (subcategories), ['Cat','Sub'] (microcategories)
|
||
function buildTimelineOption(drillPath) {
|
||
drillPath = drillPath || [];
|
||
const depth = drillPath.length; // 0=category, 1=subcategory, 2=microcategory
|
||
const months = availableMonths;
|
||
const xLabels = months.map(m => formatMonthLabel(m));
|
||
|
||
const seriesList = [];
|
||
const legendData = [];
|
||
|
||
const emphLabel = {
|
||
show: true, position: 'top',
|
||
formatter: (p) => p.value >= 1000 ? Math.round(p.value / 1000) + 'к' : '',
|
||
fontSize: 14, fontWeight: 'bold',
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
|
||
color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4]
|
||
};
|
||
|
||
// Hierarchy fields in order: category → subcategory → microcategory
|
||
const hierarchyFields = ['category', 'subcategory', 'microcategory'];
|
||
|
||
// Collect totals grouped by the next level in the hierarchy
|
||
const groupTotals = {};
|
||
|
||
months.forEach((month, mi) => {
|
||
const data = monthDataCache[month];
|
||
if (!data) return;
|
||
data.forEach(item => {
|
||
const amount = Math.abs(parseFloat(item.amount_rub));
|
||
if (isNaN(amount)) return;
|
||
|
||
// Filter: item must match all path segments
|
||
for (let i = 0; i < depth; i++) {
|
||
const field = hierarchyFields[i];
|
||
const itemVal = item[field] || '';
|
||
if (itemVal !== drillPath[i]) return;
|
||
}
|
||
|
||
// Group by the next field in the hierarchy
|
||
const groupField = hierarchyFields[depth];
|
||
const groupName = item[groupField] || 'Другое';
|
||
if (!groupTotals[groupName]) {
|
||
groupTotals[groupName] = new Array(months.length).fill(0);
|
||
}
|
||
groupTotals[groupName][mi] += amount;
|
||
});
|
||
});
|
||
|
||
// Build series with appropriate ordering and colors per depth level
|
||
if (depth === 0) {
|
||
// Level 0: use categoryOrder and categoryColors
|
||
const usedCategories = new Set();
|
||
categoryOrder.forEach((catName, ci) => {
|
||
if (!groupTotals[catName]) return;
|
||
usedCategories.add(catName);
|
||
legendData.push(catName);
|
||
seriesList.push({
|
||
name: catName,
|
||
type: 'bar',
|
||
stack: 'total',
|
||
barMaxWidth: 50,
|
||
barCategoryGap: '35%',
|
||
data: groupTotals[catName],
|
||
itemStyle: { color: categoryColors[ci] },
|
||
label: { show: false },
|
||
emphasis: { focus: 'series', label: emphLabel },
|
||
blur: { itemStyle: { opacity: 0.15 } }
|
||
});
|
||
});
|
||
|
||
Object.keys(groupTotals).forEach(catName => {
|
||
if (usedCategories.has(catName)) return;
|
||
legendData.push(catName);
|
||
seriesList.push({
|
||
name: catName,
|
||
type: 'bar',
|
||
stack: 'total',
|
||
barMaxWidth: 50,
|
||
barCategoryGap: '35%',
|
||
data: groupTotals[catName],
|
||
label: { show: false },
|
||
emphasis: { focus: 'series', label: emphLabel },
|
||
blur: { itemStyle: { opacity: 0.15 } }
|
||
});
|
||
});
|
||
} else if (depth === 1) {
|
||
// Level 1: use subcategoryOrder and getSubcategoryColor
|
||
const parentCategory = drillPath[0];
|
||
const order = subcategoryOrder[parentCategory] || [];
|
||
const usedSubs = new Set();
|
||
let unknownIdx = 0;
|
||
|
||
order.forEach((subName, si) => {
|
||
if (!groupTotals[subName]) return;
|
||
usedSubs.add(subName);
|
||
legendData.push(subName);
|
||
seriesList.push({
|
||
name: subName,
|
||
type: 'bar',
|
||
stack: 'total',
|
||
barMaxWidth: 50,
|
||
barCategoryGap: '35%',
|
||
data: groupTotals[subName],
|
||
itemStyle: { color: getSubcategoryColor(parentCategory, subName, si) },
|
||
label: { show: false },
|
||
emphasis: { focus: 'series', label: emphLabel },
|
||
blur: { itemStyle: { opacity: 0.15 } }
|
||
});
|
||
});
|
||
|
||
Object.keys(groupTotals).forEach(subName => {
|
||
if (usedSubs.has(subName)) return;
|
||
legendData.push(subName);
|
||
seriesList.push({
|
||
name: subName,
|
||
type: 'bar',
|
||
stack: 'total',
|
||
barMaxWidth: 50,
|
||
barCategoryGap: '35%',
|
||
data: groupTotals[subName],
|
||
itemStyle: { color: getSubcategoryColor(parentCategory, subName, 0, unknownIdx++) },
|
||
label: { show: false },
|
||
emphasis: { focus: 'series', label: emphLabel },
|
||
blur: { itemStyle: { opacity: 0.15 } }
|
||
});
|
||
});
|
||
} else {
|
||
// Level 2 (microcategory): auto-colors from defaultColorPalette
|
||
let colorIdx = 0;
|
||
// Sort by total value descending for consistent ordering
|
||
const sortedNames = Object.keys(groupTotals).sort((a, b) => {
|
||
const sumA = groupTotals[a].reduce((s, v) => s + v, 0);
|
||
const sumB = groupTotals[b].reduce((s, v) => s + v, 0);
|
||
return sumB - sumA;
|
||
});
|
||
|
||
sortedNames.forEach(name => {
|
||
legendData.push(name);
|
||
seriesList.push({
|
||
name: name,
|
||
type: 'bar',
|
||
stack: 'total',
|
||
barMaxWidth: 50,
|
||
barCategoryGap: '35%',
|
||
data: groupTotals[name],
|
||
itemStyle: { color: defaultColorPalette[colorIdx % defaultColorPalette.length] },
|
||
label: { show: false },
|
||
emphasis: { focus: 'series', label: emphLabel },
|
||
blur: { itemStyle: { opacity: 0.15 } }
|
||
});
|
||
colorIdx++;
|
||
});
|
||
}
|
||
|
||
// Build per-series data map for dynamic total recalculation
|
||
const seriesDataMap = {};
|
||
seriesList.forEach(s => { seriesDataMap[s.name] = s.data; });
|
||
|
||
// Invisible "total" series on top — shows sum labels that update with legend selection
|
||
const totalPerMonth = new Array(months.length).fill(0);
|
||
seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
|
||
|
||
seriesList.push({
|
||
name: '__total__',
|
||
type: 'bar',
|
||
stack: 'total',
|
||
barMaxWidth: 50,
|
||
data: totalPerMonth.map(() => 1),
|
||
itemStyle: { color: 'transparent' },
|
||
label: {
|
||
show: true,
|
||
position: 'top',
|
||
formatter: (params) => Math.round(totalPerMonth[params.dataIndex] / 1000) + 'к',
|
||
fontSize: (window.innerWidth <= 850 && window.innerHeight > window.innerWidth) ? 12 : 14,
|
||
fontWeight: 'bold',
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
|
||
color: '#666'
|
||
},
|
||
emphasis: { disabled: true },
|
||
blur: { label: { show: false }, itemStyle: { opacity: 0 } }
|
||
});
|
||
|
||
|
||
|
||
const isDrilled = drillPath.length > 0;
|
||
const isMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||
|
||
const chartTitle = isDrilled ? {
|
||
text: '← ' + drillPath.join(' › '),
|
||
left: 'center',
|
||
top: 6,
|
||
textStyle: {
|
||
fontSize: isMobile ? 16 : 22,
|
||
fontWeight: 'bold',
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
|
||
color: '#333'
|
||
},
|
||
triggerEvent: true
|
||
} : { show: false };
|
||
|
||
const legendItemWidth = isMobile ? 10 : 12;
|
||
const legendItemHeight = isMobile ? 10 : 12;
|
||
const legendItemGap = isMobile ? 6 : 8;
|
||
const legendPadding = isMobile ? [4, 8] : [8, 12];
|
||
const legendFontSize = isMobile ? 11 : 13;
|
||
|
||
const legendPosition = isMobile
|
||
? { bottom: 20, left: 'center' }
|
||
: { top: isDrilled ? 42 : 10, right: 10 };
|
||
|
||
return {
|
||
backgroundColor: '#fff',
|
||
title: chartTitle,
|
||
tooltip: { show: false },
|
||
legend: {
|
||
data: legendData,
|
||
...legendPosition,
|
||
orient: isMobile ? 'horizontal' : 'vertical',
|
||
itemWidth: legendItemWidth,
|
||
itemHeight: legendItemHeight,
|
||
textStyle: { fontSize: legendFontSize },
|
||
itemGap: legendItemGap,
|
||
backgroundColor: isMobile ? 'transparent' : 'rgba(255,255,255,0.85)',
|
||
borderRadius: isMobile ? 0 : 6,
|
||
padding: legendPadding,
|
||
selector: false
|
||
},
|
||
grid: {
|
||
left: isMobile ? 10 : 30,
|
||
right: isMobile ? 25 : 180,
|
||
top: isMobile ? (isDrilled ? 40 : 10) : (isDrilled ? 55 : 40),
|
||
bottom: isMobile ? 100 : 30,
|
||
containLabel: true
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
data: xLabels,
|
||
axisLabel: { fontSize: isMobile ? 11 : 13, rotate: isMobile ? -90 : -45 }
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
axisLabel: {
|
||
formatter: function(val) {
|
||
if (val >= 1000) return Math.round(val / 1000) + 'к\u202F₽';
|
||
return val;
|
||
},
|
||
fontSize: isMobile ? 11 : 13
|
||
}
|
||
},
|
||
series: seriesList,
|
||
_seriesDataMap: seriesDataMap,
|
||
_monthCount: months.length,
|
||
_legendLayout: {
|
||
isMobile: isMobile,
|
||
orient: isMobile ? 'horizontal' : 'vertical',
|
||
top: legendPosition.top,
|
||
right: legendPosition.right,
|
||
itemWidth: legendItemWidth,
|
||
itemHeight: legendItemHeight,
|
||
itemGap: legendItemGap,
|
||
padding: legendPadding,
|
||
fontSize: legendFontSize
|
||
}
|
||
};
|
||
}
|
||
|
||
let timelineDrillPath = [];
|
||
let _lastTimelineMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||
|
||
// Render (or re-render) the timeline chart, optionally drilled into a category path
|
||
// historyAction: 'push' (default, user drill-down), 'replace' (initial/view switch), 'none' (popstate restore)
|
||
function renderTimelineChart(drillPath, historyAction, legendSelected) {
|
||
_lastTimelineMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||
drillPath = drillPath || [];
|
||
historyAction = historyAction || 'push';
|
||
timelineDrillPath = drillPath;
|
||
window._timelineResetLegend = null;
|
||
|
||
myChart.off('click');
|
||
myChart.off('mouseover');
|
||
myChart.off('mouseout');
|
||
myChart.off('globalout');
|
||
myChart.off('legendselectchanged');
|
||
// Clean up previous zrender label-click handler
|
||
if (window._timelineZrLabelHandler) {
|
||
myChart.getZr().off('click', window._timelineZrLabelHandler);
|
||
window._timelineZrLabelHandler = null;
|
||
}
|
||
|
||
const tlOption = buildTimelineOption(drillPath);
|
||
myChart.clear();
|
||
myChart.setOption(tlOption, true);
|
||
|
||
// Restore legend selection if provided (from popstate)
|
||
if (legendSelected) {
|
||
window._suppressLegendHistory = true;
|
||
Object.keys(legendSelected).forEach(name => {
|
||
myChart.dispatchAction({
|
||
type: legendSelected[name] === false ? 'legendUnSelect' : 'legendSelect',
|
||
name: name
|
||
});
|
||
});
|
||
window._suppressLegendHistory = false;
|
||
// Update totals and reset button for the restored selection
|
||
updateTotalsAndResetBtn(legendSelected);
|
||
}
|
||
|
||
// Update browser history state
|
||
const histState = { timelineDrill: drillPath };
|
||
if (legendSelected) histState.legendSelected = legendSelected;
|
||
if (historyAction === 'replace') {
|
||
history.replaceState(histState, '');
|
||
} else if (historyAction === 'push') {
|
||
history.pushState(histState, '');
|
||
}
|
||
// 'none': skip history manipulation (popstate restore)
|
||
|
||
saveMonthSelectionState();
|
||
|
||
// Highlight entire series on hover so all bars show emphasis.label
|
||
// Also highlight the corresponding legend item
|
||
const legendNames = tlOption.legend.data;
|
||
let hlSeries = null;
|
||
|
||
function updateLegend(activeName) {
|
||
myChart.setOption({
|
||
legend: {
|
||
data: legendNames.map(name => {
|
||
if (!activeName) return { name, textStyle: { fontWeight: 'normal', color: '#333' }, itemStyle: { opacity: 1 } };
|
||
if (name === activeName) return { name, textStyle: { fontWeight: 'bold', color: '#333' }, itemStyle: { opacity: 1 } };
|
||
return { name, textStyle: { fontWeight: 'normal', color: '#ccc' }, itemStyle: { opacity: 0.15 } };
|
||
})
|
||
}
|
||
});
|
||
}
|
||
|
||
myChart.on('mouseover', function(params) {
|
||
if (params.componentType !== 'series' || params.seriesName === '__total__') return;
|
||
if (hlSeries === params.seriesName) return;
|
||
if (hlSeries) myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
|
||
hlSeries = params.seriesName;
|
||
myChart.dispatchAction({ type: 'highlight', seriesName: params.seriesName });
|
||
updateLegend(params.seriesName);
|
||
});
|
||
myChart.on('mouseout', function(params) {
|
||
if (params.componentType !== 'series' || !hlSeries) return;
|
||
myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
|
||
hlSeries = null;
|
||
updateLegend(null);
|
||
});
|
||
myChart.on('globalout', function() {
|
||
if (hlSeries) {
|
||
myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
|
||
hlSeries = null;
|
||
updateLegend(null);
|
||
}
|
||
});
|
||
|
||
// Track whether all series are selected
|
||
function updateTotalsAndResetBtn(selected) {
|
||
const dataMap = tlOption._seriesDataMap;
|
||
const n = tlOption._monthCount;
|
||
const visibleTotals = new Array(n).fill(0);
|
||
const allNames = tlOption.legend.data;
|
||
const legendLayout = tlOption._legendLayout;
|
||
let allSelected = true;
|
||
Object.keys(dataMap).forEach(name => {
|
||
if (selected[name] !== false) {
|
||
dataMap[name].forEach((v, i) => { visibleTotals[i] += v; });
|
||
} else {
|
||
allSelected = false;
|
||
}
|
||
});
|
||
const resetFontSize = Math.max(11, legendLayout.fontSize - 1);
|
||
|
||
const resetLegendSelection = function() {
|
||
window._suppressLegendHistory = true;
|
||
allNames.forEach(name => {
|
||
myChart.dispatchAction({ type: 'legendSelect', name: name });
|
||
});
|
||
window._suppressLegendHistory = false;
|
||
const resetSelected = {};
|
||
allNames.forEach(name => { resetSelected[name] = true; });
|
||
updateTotalsAndResetBtn(resetSelected);
|
||
history.pushState({ timelineDrill: drillPath }, '');
|
||
};
|
||
window._timelineResetLegend = allSelected ? null : resetLegendSelection;
|
||
|
||
const resetGraphic = {
|
||
type: 'text',
|
||
id: 'resetBtn',
|
||
z: 100,
|
||
zlevel: 1,
|
||
style: {
|
||
text: allSelected ? '' : '× сбросить',
|
||
fontSize: resetFontSize,
|
||
fill: '#999',
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif'
|
||
},
|
||
cursor: 'pointer',
|
||
onmouseover: function() {
|
||
if (this && this.setStyle) this.setStyle({ fill: '#000' });
|
||
},
|
||
onmouseout: function() {
|
||
if (this && this.setStyle) this.setStyle({ fill: '#999' });
|
||
},
|
||
onclick: resetLegendSelection
|
||
};
|
||
|
||
const legendView = (myChart._componentsViews || []).find(v => v && v.__model && v.__model.mainType === 'legend');
|
||
|
||
if (legendLayout.isMobile) {
|
||
// Mobile: center reset button below horizontal legend
|
||
let resetTop;
|
||
if (legendView && legendView.group) {
|
||
const groupRect = legendView.group.getBoundingRect();
|
||
resetTop = legendView.group.y + groupRect.y + groupRect.height + 2;
|
||
}
|
||
resetGraphic.left = 'center';
|
||
if (resetTop !== undefined) {
|
||
resetGraphic.top = resetTop;
|
||
} else {
|
||
resetGraphic.bottom = 2;
|
||
}
|
||
} else {
|
||
// Desktop: position below vertical legend, left-aligned with legend box
|
||
const legendFont = `${legendLayout.fontSize}px sans-serif`;
|
||
const resetXOffset = 3;
|
||
const maxLegendTextWidth = allNames.reduce((maxWidth, name) => {
|
||
return Math.max(maxWidth, echarts.format.getTextRect(name, legendFont).width);
|
||
}, 0);
|
||
const iconTextGap = 5;
|
||
const legendBoxWidth =
|
||
legendLayout.padding[1] * 2 +
|
||
legendLayout.itemWidth +
|
||
iconTextGap +
|
||
maxLegendTextWidth;
|
||
const resetTextWidth = echarts.format.getTextRect('× сбросить', `${resetFontSize}px sans-serif`).width;
|
||
const resetRight =
|
||
legendLayout.right +
|
||
legendBoxWidth -
|
||
legendLayout.padding[1] -
|
||
resetTextWidth -
|
||
resetXOffset;
|
||
const resetLeft = legendView && legendView.group ? legendView.group.x : null;
|
||
let resetTop;
|
||
if (legendView && legendView.group && legendView._contentGroup) {
|
||
const legendItems = legendView._contentGroup.children();
|
||
if (legendItems.length > 0) {
|
||
const lastItem = legendItems[legendItems.length - 1];
|
||
const prevItem = legendItems.length > 1 ? legendItems[legendItems.length - 2] : null;
|
||
const rowStep = prevItem ? (lastItem.y - prevItem.y) : (legendLayout.itemHeight + legendLayout.itemGap);
|
||
const lastRect = lastItem.getBoundingRect();
|
||
resetTop =
|
||
legendView.group.y +
|
||
(legendView._contentGroup.y || 0) +
|
||
lastItem.y +
|
||
lastRect.y +
|
||
rowStep;
|
||
}
|
||
}
|
||
if (resetTop === undefined) {
|
||
const nextRowTop =
|
||
legendLayout.top +
|
||
legendLayout.padding[0] +
|
||
allNames.length * (legendLayout.itemHeight + legendLayout.itemGap);
|
||
resetTop = nextRowTop + 11;
|
||
}
|
||
resetGraphic.top = resetTop;
|
||
if (resetLeft !== null) {
|
||
resetGraphic.left = resetLeft + resetXOffset;
|
||
} else {
|
||
resetGraphic.right = resetRight;
|
||
}
|
||
}
|
||
|
||
myChart.setOption({
|
||
series: [{ name: '__total__', label: {
|
||
formatter: (p) => Math.round(visibleTotals[p.dataIndex] / 1000) + 'к'
|
||
}}],
|
||
graphic: [resetGraphic]
|
||
});
|
||
}
|
||
|
||
// Update total labels when legend selection changes (cmd-click / opt-click / legend click)
|
||
myChart.on('legendselectchanged', function(params) {
|
||
updateTotalsAndResetBtn(params.selected);
|
||
// Push legend selection to browser history (skip during popstate restore)
|
||
if (!window._suppressLegendHistory) {
|
||
history.pushState({ timelineDrill: drillPath, legendSelected: params.selected }, '');
|
||
}
|
||
});
|
||
|
||
// Click handler: drill deeper or go up via title
|
||
myChart.on('click', function(params) {
|
||
if (params.componentType === 'title') {
|
||
// Go up one level
|
||
renderTimelineChart(drillPath.slice(0, -1));
|
||
return;
|
||
}
|
||
if (params.componentType === 'series' && params.seriesName !== '__total__') {
|
||
const nativeEvent = params.event && params.event.event;
|
||
// Cmd-click: select only this series (hide all others)
|
||
if (nativeEvent && nativeEvent.metaKey) {
|
||
const allNames = tlOption.legend.data;
|
||
window._suppressLegendHistory = true;
|
||
allNames.forEach(name => {
|
||
myChart.dispatchAction({
|
||
type: name === params.seriesName ? 'legendSelect' : 'legendUnSelect',
|
||
name: name
|
||
});
|
||
});
|
||
window._suppressLegendHistory = false;
|
||
// Build selected map and push once
|
||
const selected = {};
|
||
allNames.forEach(name => { selected[name] = name === params.seriesName; });
|
||
updateTotalsAndResetBtn(selected);
|
||
history.pushState({ timelineDrill: drillPath, legendSelected: selected }, '');
|
||
return;
|
||
}
|
||
// Opt-click: toggle this series off (single action — legendselectchanged handles push)
|
||
if (nativeEvent && nativeEvent.altKey) {
|
||
myChart.dispatchAction({ type: 'legendToggleSelect', name: params.seriesName });
|
||
return;
|
||
}
|
||
// Plain click: drill deeper if not at max depth (2 = microcategory level)
|
||
if (drillPath.length < 2) {
|
||
renderTimelineChart([...drillPath, params.seriesName]);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Zrender-level click: detect clicks in the x-axis label area → switch to that month
|
||
window._timelineZrLabelHandler = function(e) {
|
||
const y = e.offsetY;
|
||
const yBottom = myChart.convertToPixel({yAxisIndex: 0}, 0);
|
||
// Click must be below the bar area (in the label zone) but above the legend
|
||
if (y > yBottom + 5 && y < yBottom + 60) {
|
||
const x = e.offsetX;
|
||
// Find nearest bar index
|
||
let bestIdx = -1, bestDist = Infinity;
|
||
for (let i = 0; i < availableMonths.length; i++) {
|
||
const barX = myChart.convertToPixel({xAxisIndex: 0}, i);
|
||
const dist = Math.abs(x - barX);
|
||
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
|
||
}
|
||
// Accept if within half the bar spacing
|
||
const spacing = availableMonths.length > 1
|
||
? Math.abs(myChart.convertToPixel({xAxisIndex: 0}, 1) - myChart.convertToPixel({xAxisIndex: 0}, 0))
|
||
: 100;
|
||
if (bestIdx >= 0 && bestDist < spacing * 0.6) {
|
||
history.pushState({ monthView: true }, '');
|
||
switchView('month');
|
||
selectSingleMonth(bestIdx);
|
||
}
|
||
}
|
||
};
|
||
myChart.getZr().on('click', window._timelineZrLabelHandler);
|
||
}
|
||
|
||
// Switch between month (sunburst) and timeline (stacked bar) views
|
||
function switchView(viewName) {
|
||
if (viewName === currentView) return;
|
||
currentView = viewName;
|
||
|
||
// Update switcher button active state
|
||
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.view === viewName);
|
||
});
|
||
|
||
const container = document.querySelector('.container');
|
||
|
||
if (viewName === 'timeline') {
|
||
container.classList.add('timeline-mode');
|
||
// Remove all chart event listeners
|
||
myChart.off('click');
|
||
myChart.off('mouseover');
|
||
myChart.off('mouseout');
|
||
myChart.off('globalout');
|
||
myChart.off('legendselectchanged');
|
||
// Resize after layout change, then render stacked bar
|
||
myChart.resize();
|
||
renderTimelineChart(timelineDrillPath, 'replace');
|
||
// Ensure resize after CSS layout fully settles (needed for deferred restore)
|
||
requestAnimationFrame(() => myChart.resize());
|
||
} else {
|
||
container.classList.remove('timeline-mode');
|
||
window._timelineResetLegend = null;
|
||
// Remove all chart event listeners
|
||
myChart.off('click');
|
||
myChart.off('mouseover');
|
||
myChart.off('mouseout');
|
||
myChart.off('globalout');
|
||
myChart.off('legendselectchanged');
|
||
// Clear chart completely so no bar chart remnants remain
|
||
myChart.clear();
|
||
option = null;
|
||
// Resize after layout change, then re-render sunburst from scratch
|
||
myChart.resize();
|
||
renderSelectedMonths();
|
||
}
|
||
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Function to get Russian month name from YYYY-MM format
|
||
function getRussianMonthName(dateStr) {
|
||
const monthNum = parseInt(dateStr.split('-')[1]);
|
||
const russianMonths = [
|
||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||
];
|
||
return russianMonths[monthNum - 1];
|
||
}
|
||
|
||
// Function to render the chart
|
||
function renderChart(data) {
|
||
const sunburstData = transformToSunburst(data);
|
||
const restoredPath = [...currentDrillPath];
|
||
|
||
// Store the original data for resetting (module-level variable)
|
||
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
|
||
|
||
// Reset and initialize history with the root state
|
||
resetHistory();
|
||
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
|
||
|
||
// Get the currently selected month label
|
||
const selectedMonth = document.getElementById('month-select').value;
|
||
const russianMonth = selectedMonthIndices.size > 1
|
||
? generateSelectedMonthsLabel()
|
||
: getRussianMonthName(selectedMonth);
|
||
|
||
// Calculate the correct center position first
|
||
const screenWidth = window.innerWidth;
|
||
let centerPosition;
|
||
|
||
if (screenWidth <= 850) {
|
||
// Stacked layout: center the chart since details box is below
|
||
centerPosition = 50;
|
||
} else if (screenWidth >= 1000) {
|
||
// For screens 1000px and wider, keep centered at 50%
|
||
centerPosition = 50;
|
||
} else {
|
||
// Gradual transition between 850-1000px (side-by-side layout)
|
||
const transitionProgress = (screenWidth - 850) / 150; // 0 to 1
|
||
centerPosition = 40 + (transitionProgress * 10); // 40 to 50
|
||
}
|
||
|
||
// Mobile: scale chart to fill container, hide outside labels
|
||
const isMobile = screenWidth <= 500;
|
||
|
||
// Mobile: extend layers to fill container, keeping same hole size as desktop
|
||
// Hole stays at 20%, layers scaled to fill remaining 80% (vs 55% on desktop)
|
||
const level1Inner = '20%';
|
||
const level1Outer = isMobile ? '56%' : '45%';
|
||
const level2Outer = isMobile ? '93%' : '70%';
|
||
const level3Outer = isMobile ? '100%' : '75%';
|
||
const outerRadius = isMobile ? '100%' : '95%';
|
||
|
||
// Smaller font sizes on mobile, bolder for readability
|
||
const level1FontSize = isMobile ? 10 : 13;
|
||
const level1LineHeight = isMobile ? 12 : 15;
|
||
const level1FontWeight = isMobile ? 600 : 500;
|
||
const level2FontSize = isMobile ? 9 : 11;
|
||
const level2FontWeight = isMobile ? 600 : 400;
|
||
|
||
option = {
|
||
backgroundColor: '#fff',
|
||
grid: {
|
||
left: '10%',
|
||
containLabel: true
|
||
},
|
||
animation: true,
|
||
//animationThreshold: 2000,
|
||
//animationDuration: 1000,
|
||
//animationEasing: 'cubicOut',
|
||
//animationDurationUpdate: 500,
|
||
//animationEasingUpdate: 'cubicInOut',
|
||
series: {
|
||
type: 'sunburst',
|
||
radius: [0, outerRadius],
|
||
center: [`${centerPosition}%`, '50%'],
|
||
startAngle: 0,
|
||
nodeClick: false,
|
||
data: sunburstData.data,
|
||
sort: null, // Use 'null' to maintain the sorting we did in the data transformation
|
||
label: {
|
||
show: true,
|
||
formatter: function(param) {
|
||
if (param.depth === 0) {
|
||
// No word wrapping for top-level categories
|
||
return param.name;
|
||
} else {
|
||
return '';
|
||
}
|
||
},
|
||
minAngle: 5,
|
||
align: 'center',
|
||
verticalAlign: 'middle',
|
||
position: 'inside'
|
||
},
|
||
itemStyle: {
|
||
borderWidth: 1,
|
||
borderColor: '#fff'
|
||
},
|
||
levels: [
|
||
{},
|
||
{
|
||
// First level - Categories
|
||
r0: level1Inner,
|
||
r: level1Outer,
|
||
label: {
|
||
show: true,
|
||
rotate: 'radial',
|
||
fontSize: level1FontSize,
|
||
lineHeight: level1LineHeight,
|
||
fontWeight: level1FontWeight,
|
||
verticalAlign: 'center',
|
||
position: 'inside',
|
||
formatter: function(param) {
|
||
// No special formatting for level 1
|
||
return param.name;
|
||
}
|
||
},
|
||
itemStyle: {
|
||
borderWidth: 2
|
||
}
|
||
},
|
||
{
|
||
// Second level - Subcategories
|
||
r0: level1Outer,
|
||
r: level2Outer,
|
||
label: {
|
||
show: function(param) {
|
||
// Show labels for sectors that are at least 5% of the total
|
||
return param.percent >= 0.05;
|
||
},
|
||
fontSize: level2FontSize,
|
||
fontWeight: level2FontWeight,
|
||
align: 'center',
|
||
position: 'inside',
|
||
distance: 5,
|
||
|
||
formatter: function(param) {
|
||
// If there's only one word, never wrap it
|
||
if (!param.name.includes(' ')) {
|
||
return param.name;
|
||
}
|
||
|
||
// If the text contains spaces, consider word wrapping for better visibility
|
||
const words = param.name.split(' ');
|
||
|
||
// Skip wrapping for single words or very small sectors
|
||
// Estimate sector size from value percentage
|
||
if (words.length === 1 || param.percent < 0.03) {
|
||
return param.name;
|
||
}
|
||
|
||
// Process words to keep short prepositions (< 4 chars) with the next word
|
||
const processedWords = [];
|
||
let i = 0;
|
||
while (i < words.length) {
|
||
if (i < words.length - 1 && words[i].length < 4) {
|
||
// Combine short word with the next word
|
||
processedWords.push(words[i] + ' ' + words[i+1]);
|
||
i += 2;
|
||
} else {
|
||
processedWords.push(words[i]);
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// Skip wrapping if we're down to just one processed word
|
||
if (processedWords.length === 1) {
|
||
return processedWords[0];
|
||
}
|
||
|
||
// If only 2 processed words, put one on each line
|
||
if (processedWords.length == 2) {
|
||
return processedWords[0] + '\n' + processedWords[1];
|
||
}
|
||
// If 3 processed words, put each word on its own line
|
||
else if (processedWords.length == 3) {
|
||
return processedWords[0] + '\n' + processedWords[1] + '\n' + processedWords[2];
|
||
}
|
||
// For more words, split more aggressively
|
||
else if (processedWords.length > 3) {
|
||
// Try to create 3 relatively even lines
|
||
const part1 = Math.floor(processedWords.length / 3);
|
||
const part2 = Math.floor(processedWords.length * 2 / 3);
|
||
|
||
return processedWords.slice(0, part1).join(' ') + '\n' +
|
||
processedWords.slice(part1, part2).join(' ') + '\n' +
|
||
processedWords.slice(part2).join(' ');
|
||
}
|
||
|
||
return param.name;
|
||
}
|
||
},
|
||
itemStyle: {
|
||
borderWidth: 1
|
||
},
|
||
emphasis: {
|
||
label: {
|
||
show: true,
|
||
distance: 20
|
||
}
|
||
}
|
||
},
|
||
{
|
||
// Third level - Microcategories
|
||
r0: level2Outer,
|
||
r: level3Outer,
|
||
label: {
|
||
// Only show labels conditionally based on segment size
|
||
// On mobile, hide outside labels to maximize chart size
|
||
show: isMobile ? false : function(param) {
|
||
// Show label if segment is wide enough (>1%)
|
||
return param.percent > 0.000;
|
||
},
|
||
position: isMobile ? 'inside' : 'outside',
|
||
padding: 3,
|
||
minAngle: 3, // Add this - default is 5, reducing it will show more labels
|
||
silent: false,
|
||
fontSize: 10,
|
||
formatter: function(param) {
|
||
// If there's only one word, never wrap it
|
||
if (!param.name.includes(' ')) {
|
||
return param.name;
|
||
}
|
||
|
||
// If the text contains spaces, consider word wrapping for better visibility
|
||
const words = param.name.split(' ');
|
||
|
||
// Skip wrapping for single words or very small sectors
|
||
// Estimate sector size from value percentage
|
||
if (words.length === 1 || param.percent < 0.02) {
|
||
return param.name;
|
||
}
|
||
|
||
// Process words to keep short prepositions (< 4 chars) with the next word
|
||
const processedWords = [];
|
||
let i = 0;
|
||
while (i < words.length) {
|
||
if (i < words.length - 1 && words[i].length < 4) {
|
||
// Combine short word with the next word
|
||
processedWords.push(words[i] + ' ' + words[i+1]);
|
||
i += 2;
|
||
} else {
|
||
processedWords.push(words[i]);
|
||
i++;
|
||
}
|
||
}
|
||
|
||
// Skip wrapping if we're down to just one processed word
|
||
if (processedWords.length === 1) {
|
||
return processedWords[0];
|
||
}
|
||
|
||
// If only 2 processed words, put one on each line
|
||
if (processedWords.length == 2) {
|
||
return processedWords[0] + '\n' + processedWords[1];
|
||
}
|
||
// If 3 processed words, put each word on its own line
|
||
else if (processedWords.length == 3) {
|
||
return processedWords[0] + '\n' + processedWords[1] + '\n' + processedWords[2];
|
||
}
|
||
// For more words, split more aggressively
|
||
else if (processedWords.length > 3) {
|
||
// Try to create 3 relatively even lines
|
||
const part1 = Math.floor(processedWords.length / 3);
|
||
const part2 = Math.floor(processedWords.length * 2 / 3);
|
||
|
||
return processedWords.slice(0, part1).join(' ') + '\n' +
|
||
processedWords.slice(part1, part2).join(' ') + '\n' +
|
||
processedWords.slice(part2).join(' ');
|
||
}
|
||
|
||
return param.name;
|
||
}
|
||
},
|
||
itemStyle: {
|
||
borderWidth: 3
|
||
}
|
||
}
|
||
],
|
||
emphasis: {
|
||
focus: 'relative'
|
||
},
|
||
// Add more space between wedges
|
||
gap: 2,
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: function(info) {
|
||
const value = formatRUB(info.value);
|
||
const name = info.name;
|
||
|
||
// Calculate percentage of total
|
||
const percentage = ((info.value / sunburstData.total) * 100).toFixed(1);
|
||
|
||
return `${name}<br/>Amount: ${value}\u202F₽<br/>Percentage: ${percentage}%`;
|
||
}
|
||
}
|
||
},
|
||
graphic: {
|
||
elements: [] // Center label is now HTML overlay
|
||
}
|
||
};
|
||
|
||
// Handle chart events - drill-down on click
|
||
myChart.off('click');
|
||
myChart.on('click', function(params) {
|
||
if (!params.data) return; // Skip if no data
|
||
|
||
const colorPalette = [
|
||
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
||
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
|
||
];
|
||
|
||
// If we have children or transactions, drill down
|
||
if ((params.data.children && params.data.children.length > 0) || (params.data.transactions && params.data.transactions.length > 0)) {
|
||
// Transform the data structure for drill-down
|
||
const newData = [];
|
||
|
||
// Handle different cases based on the type of node clicked
|
||
if (params.data.children && params.data.children.length > 0) {
|
||
// Case 1: Node has children (category or subcategory)
|
||
const sortedChildren = sortBySubcategoryOrder(params.data.children, params.name);
|
||
const predefinedOrder = subcategoryOrder[params.name] || [];
|
||
|
||
// Process each child, tracking unknown item count for color assignment
|
||
let unknownCount = 0;
|
||
sortedChildren.forEach((child, i) => {
|
||
const isUnknown = !predefinedOrder.includes(child.name);
|
||
const color = getSubcategoryColor(params.name, child.name, i, isUnknown ? unknownCount++ : null);
|
||
const newCategory = {
|
||
name: child.name,
|
||
value: child.value,
|
||
transactions: child.transactions, // Preserve for modal
|
||
itemStyle: {
|
||
color: color
|
||
},
|
||
children: []
|
||
};
|
||
|
||
// If child has children (microcategories), they become subcategories
|
||
if (child.children && child.children.length > 0) {
|
||
const sortedMicros = [...child.children].sort((a, b) => b.value - a.value);
|
||
const microColors = generateColorGradient(color, sortedMicros.length);
|
||
|
||
sortedMicros.forEach((micro, j) => {
|
||
const microCategory = {
|
||
name: micro.name,
|
||
value: micro.value,
|
||
transactions: micro.transactions, // Preserve for modal
|
||
itemStyle: {
|
||
color: microColors[j]
|
||
},
|
||
children: [] // Will hold transactions
|
||
};
|
||
|
||
// If micro has transactions, add them as children
|
||
if (micro.transactions && micro.transactions.length > 0) {
|
||
const sortedTransactions = [...micro.transactions].sort((a, b) => b.value - a.value);
|
||
const transactionColors = generateColorGradient(microColors[j], sortedTransactions.length);
|
||
|
||
sortedTransactions.forEach((transaction, k) => {
|
||
microCategory.children.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
itemStyle: {
|
||
color: transactionColors[k]
|
||
},
|
||
originalRow: transaction.originalRow
|
||
});
|
||
});
|
||
}
|
||
|
||
newCategory.children.push(microCategory);
|
||
});
|
||
}
|
||
|
||
// Add transactions without microcategory as subcategories
|
||
if (child.transactions) {
|
||
const transactionsWithoutMicro = child.transactions.filter(t => t.displayMicrocategory === '');
|
||
if (transactionsWithoutMicro.length > 0) {
|
||
// Group similar transactions
|
||
const transactionGroups = {};
|
||
transactionsWithoutMicro.forEach(t => {
|
||
if (!transactionGroups[t.name]) {
|
||
transactionGroups[t.name] = {
|
||
name: t.name,
|
||
value: 0,
|
||
transactions: []
|
||
};
|
||
}
|
||
transactionGroups[t.name].value += t.value;
|
||
transactionGroups[t.name].transactions.push(t);
|
||
});
|
||
|
||
// Add transaction groups as subcategories
|
||
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
||
const transactionColors = generateColorGradient(color, groups.length);
|
||
|
||
groups.forEach((group, j) => {
|
||
const transactionCategory = {
|
||
name: group.name,
|
||
value: group.value,
|
||
transactions: group.transactions, // Preserve for modal
|
||
itemStyle: {
|
||
color: transactionColors[j]
|
||
},
|
||
children: [] // Will hold individual transactions
|
||
};
|
||
|
||
// Add individual transactions as the third level
|
||
if (group.transactions.length > 0) {
|
||
const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value);
|
||
const individualColors = generateColorGradient(transactionColors[j], sortedTransactions.length);
|
||
|
||
sortedTransactions.forEach((transaction, k) => {
|
||
transactionCategory.children.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
itemStyle: {
|
||
color: individualColors[k]
|
||
},
|
||
originalRow: transaction.originalRow
|
||
});
|
||
});
|
||
}
|
||
|
||
newCategory.children.push(transactionCategory);
|
||
});
|
||
}
|
||
}
|
||
|
||
newData.push(newCategory);
|
||
});
|
||
} else if (params.data.transactions && params.data.transactions.length > 0) {
|
||
// Case 2: Node has transactions but no children (microcategory)
|
||
// Group transactions by name
|
||
const transactionGroups = {};
|
||
params.data.transactions.forEach(t => {
|
||
if (!transactionGroups[t.name]) {
|
||
transactionGroups[t.name] = {
|
||
name: t.name,
|
||
value: 0,
|
||
transactions: []
|
||
};
|
||
}
|
||
transactionGroups[t.name].value += t.value;
|
||
transactionGroups[t.name].transactions.push(t);
|
||
});
|
||
|
||
// Create categories from transaction groups
|
||
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
||
groups.forEach((group, i) => {
|
||
const color = colorPalette[i % colorPalette.length];
|
||
const transactionCategory = {
|
||
name: group.name,
|
||
value: group.value,
|
||
transactions: group.transactions, // Preserve for modal
|
||
itemStyle: {
|
||
color: color
|
||
},
|
||
children: [] // Will hold individual transactions
|
||
};
|
||
|
||
// Add individual transactions as subcategories
|
||
if (group.transactions.length > 0) {
|
||
const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value);
|
||
const transactionColors = generateColorGradient(color, sortedTransactions.length);
|
||
|
||
sortedTransactions.forEach((transaction, j) => {
|
||
transactionCategory.children.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
itemStyle: {
|
||
color: transactionColors[j]
|
||
},
|
||
originalRow: transaction.originalRow
|
||
});
|
||
});
|
||
}
|
||
|
||
newData.push(transactionCategory);
|
||
});
|
||
}
|
||
|
||
// Build the new path by appending the clicked category
|
||
const newPath = [...currentDrillPath, params.name];
|
||
saveToHistory(newData, params.value, params.name, false, newPath);
|
||
|
||
// Update the chart with the new data structure
|
||
option.series.data = newData;
|
||
|
||
// Update the center text to show the drilled-down category
|
||
const monthLabel = selectedMonthIndices.size > 1
|
||
? generateSelectedMonthsLabel()
|
||
: getRussianMonthName(document.getElementById('month-select').value);
|
||
myChart.setOption(option, { replaceMerge: ['series'] });
|
||
updateCenterLabel(monthLabel, params.value, params.name);
|
||
|
||
// Update hover events with the new data structure, passing the drilled-down name
|
||
setupHoverEvents({ total: params.value, data: newData }, params.name);
|
||
|
||
// Update mini-chart previews to reflect drill-down
|
||
updateAllMonthPreviews();
|
||
}
|
||
});
|
||
|
||
myChart.setOption(option);
|
||
|
||
// Try to restore drill-down path on initial render (e.g., after page reload)
|
||
const restoredState = navigateToPath(sunburstData, restoredPath);
|
||
if (restoredState) {
|
||
option.series.data = restoredState.data;
|
||
myChart.setOption(option, { replaceMerge: ['series'] });
|
||
saveToHistory(restoredState.data, restoredState.total, restoredState.contextName, false, restoredState.path);
|
||
updateCenterLabel(russianMonth, restoredState.total, restoredState.contextName);
|
||
setupHoverEvents({ total: restoredState.total, data: restoredState.data }, restoredState.contextName);
|
||
updateAllMonthPreviews();
|
||
} else {
|
||
// Update HTML center label
|
||
updateCenterLabel(russianMonth, sunburstData.total);
|
||
// Set up hover events for the details box
|
||
setupHoverEvents(sunburstData);
|
||
}
|
||
|
||
// Add click handler for the center to go back in history
|
||
const zr = myChart.getZr();
|
||
zr.on('click', function(params) {
|
||
const x = params.offsetX;
|
||
const y = params.offsetY;
|
||
|
||
// Calculate center and inner radius
|
||
const chartWidth = myChart.getWidth();
|
||
const chartHeight = myChart.getHeight();
|
||
const centerX = chartWidth * (parseFloat(option.series.center[0]) / 100);
|
||
const centerY = chartHeight * (parseFloat(option.series.center[1]) / 100);
|
||
const innerRadius = Math.min(chartWidth, chartHeight) * 0.2; // 20% of chart size
|
||
|
||
// Check if click is within the center circle
|
||
const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2));
|
||
if (distance < innerRadius && historyIndex > 0) {
|
||
history.back(); // Use browser history - triggers popstate
|
||
}
|
||
});
|
||
|
||
// Ensure chart is properly sized after rendering
|
||
adjustChartSize();
|
||
myChart.resize();
|
||
}
|
||
|
||
// Function to generate a color gradient
|
||
function generateColorGradient(baseColor, steps) {
|
||
const result = [];
|
||
const base = tinycolor(baseColor);
|
||
|
||
// Get the base hue value (0-360)
|
||
const baseHue = base.toHsl().h;
|
||
|
||
// Create a more dramatic gradient based on size
|
||
for (let i = 0; i < steps; i++) {
|
||
// Calculate percentage position in the sequence (0 to 1)
|
||
const position = i / (steps - 1 || 1);
|
||
|
||
let color = base.clone();
|
||
|
||
// Modify hue - shift around the color wheel based on size
|
||
// Smaller items (position closer to 0): shift hue towards cooler colors (-30 degrees)
|
||
// Larger items (position closer to 1): shift hue towards warmer colors (+30 degrees)
|
||
const hueShift = 15-(position*15); // Ranges from -30 to +30
|
||
|
||
// Apply HSL transformations
|
||
const hsl = color.toHsl();
|
||
//hsl.h = (baseHue + hueShift) % 360; // Keep hue within 0-360 range
|
||
|
||
// Also adjust saturation and lightness for even more distinction
|
||
/* if (position < 0.5) {
|
||
// Smaller items: more saturated, darker
|
||
hsl.s = Math.min(1, hsl.s * (1.3 - position * 0.6)); // Increase saturation up to 30%
|
||
hsl.l = Math.max(0.2, hsl.l * (0.85 + position * 0.3)); // Slightly darker
|
||
} else {
|
||
// Larger items: slightly less saturated, brighter
|
||
hsl.s = Math.min(1, hsl.s * (0.9 + position * 0.2)); // Slightly reduce saturation
|
||
hsl.l = Math.min(0.9, hsl.l * (1.1 + (position - 0.5) * 0.2)); // Brighter
|
||
}*/
|
||
|
||
result.push(tinycolor(hsl).toString());
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// Load TinyColor library for color manipulation
|
||
function loadTinyColor() {
|
||
return new Promise((resolve, reject) => {
|
||
if (window.tinycolor) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const script = document.createElement('script');
|
||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/tinycolor/1.4.2/tinycolor.min.js';
|
||
script.onload = resolve;
|
||
script.onerror = reject;
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
// Global state for month navigation
|
||
let availableMonths = [];
|
||
let currentMonthIndex = 0;
|
||
let selectedMonthIndices = new Set(); // Track all selected months for multi-selection
|
||
let monthDataCache = {}; // Cache for month data previews
|
||
const MONTH_SELECTION_STORAGE_KEY = 'visual-spending:month-selection';
|
||
|
||
function saveMonthSelectionState() {
|
||
try {
|
||
const selectedMonths = [...selectedMonthIndices]
|
||
.map(index => availableMonths[index])
|
||
.filter(Boolean);
|
||
const currentMonth = availableMonths[currentMonthIndex] || null;
|
||
|
||
localStorage.setItem(MONTH_SELECTION_STORAGE_KEY, JSON.stringify({
|
||
currentMonth,
|
||
selectedMonths,
|
||
drillPath: [...currentDrillPath],
|
||
view: currentView,
|
||
timelineDrillPath: [...timelineDrillPath]
|
||
}));
|
||
} catch (error) {
|
||
// Ignore storage failures (private mode, disabled storage, etc.)
|
||
}
|
||
}
|
||
|
||
function restoreMonthSelectionState() {
|
||
try {
|
||
const raw = localStorage.getItem(MONTH_SELECTION_STORAGE_KEY);
|
||
if (!raw) return false;
|
||
|
||
const saved = JSON.parse(raw);
|
||
if (!saved || !Array.isArray(saved.selectedMonths) || saved.selectedMonths.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
const restoredIndices = saved.selectedMonths
|
||
.map(month => availableMonths.indexOf(month))
|
||
.filter(index => index >= 0);
|
||
|
||
if (restoredIndices.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
selectedMonthIndices = new Set(restoredIndices);
|
||
|
||
const restoredCurrentIndex = availableMonths.indexOf(saved.currentMonth);
|
||
if (restoredCurrentIndex >= 0 && selectedMonthIndices.has(restoredCurrentIndex)) {
|
||
currentMonthIndex = restoredCurrentIndex;
|
||
} else {
|
||
currentMonthIndex = Math.max(...selectedMonthIndices);
|
||
}
|
||
|
||
if (Array.isArray(saved.drillPath)) {
|
||
currentDrillPath = [...saved.drillPath];
|
||
} else {
|
||
currentDrillPath = [];
|
||
}
|
||
|
||
// Restore timeline drill path if saved
|
||
if (Array.isArray(saved.timelineDrillPath)) {
|
||
timelineDrillPath = [...saved.timelineDrillPath];
|
||
}
|
||
|
||
// Defer timeline restore so chart is initialized first
|
||
if (saved.view === 'timeline') {
|
||
requestAnimationFrame(() => switchView('timeline'));
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 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 = [
|
||
'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи',
|
||
'Здоровье', 'Логистика', 'Расходники', 'Красота'
|
||
];
|
||
|
||
// Fixed subcategory colors based on December 2025 data (sorted by value)
|
||
// This ensures consistent colors across all months
|
||
const subcategoryColors = {
|
||
'Квартира': {
|
||
'ЖКХ': '#5470c6',
|
||
'Лев Петрович': '#91cc75'
|
||
},
|
||
'Еда': {
|
||
'Продукты': '#5470c6',
|
||
'Готовая': '#91cc75'
|
||
},
|
||
'Технологии': {
|
||
'Инфраструктура домашнего интернета': '#5470c6',
|
||
'Умный дом': '#91cc75',
|
||
'AI': '#fac858',
|
||
'Мобильная связь': '#ee6666'
|
||
},
|
||
'Развлечения': {
|
||
'Подарки': '#5470c6',
|
||
'Хобби Антона': '#91cc75',
|
||
'Хобби Залины': '#fac858',
|
||
'Подписки': '#ee6666',
|
||
'Чтение': '#73c0de',
|
||
'Работа Залины': '#3ba272',
|
||
'Девайсы': '#fc8452'
|
||
},
|
||
'Семьи': {
|
||
'Залина': '#5470c6',
|
||
'Антон': '#91cc75'
|
||
},
|
||
'Здоровье': {
|
||
'Терапия': '#5470c6',
|
||
'Лекарства': '#91cc75',
|
||
'Спортивное питание': '#fac858'
|
||
},
|
||
'Логистика': {
|
||
'Такси': '#5470c6',
|
||
'Доставка': '#91cc75',
|
||
'Чаевые': '#fac858'
|
||
},
|
||
'Расходники': {
|
||
'Замена разных фильтров': '#5470c6',
|
||
'Санитарное': '#91cc75',
|
||
'Батарейки': '#fac858',
|
||
'Мелкий ремонт': '#ee6666',
|
||
'Средства для посудомоек': '#73c0de'
|
||
},
|
||
'Красота': {
|
||
'Одежда': '#5470c6',
|
||
'Салоны красоты': '#91cc75',
|
||
'Кремы': '#fac858'
|
||
}
|
||
};
|
||
|
||
// Color palette for items not in the predefined mapping
|
||
const defaultColorPalette = [
|
||
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
||
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
|
||
];
|
||
|
||
// Fixed subcategory order derived from subcategoryColors (December 2025 order)
|
||
const subcategoryOrder = {};
|
||
for (const category in subcategoryColors) {
|
||
subcategoryOrder[category] = Object.keys(subcategoryColors[category]);
|
||
}
|
||
|
||
// Get color for a subcategory, using fixed mapping or fallback to index-based
|
||
// unknownIndex: for unknown subcategories, pass the count of unknown items before this one
|
||
function getSubcategoryColor(category, subcategory, index, unknownIndex = null) {
|
||
if (subcategoryColors[category] && subcategoryColors[category][subcategory]) {
|
||
return subcategoryColors[category][subcategory];
|
||
}
|
||
// For unknown subcategories, use colors starting after the predefined ones
|
||
const predefinedCount = subcategoryOrder[category] ? subcategoryOrder[category].length : 0;
|
||
// Use unknownIndex if provided, otherwise calculate from index minus predefined items that might be before
|
||
const colorIndex = unknownIndex !== null ? unknownIndex : index;
|
||
return defaultColorPalette[(predefinedCount + colorIndex) % defaultColorPalette.length];
|
||
}
|
||
|
||
// Sort items by fixed subcategory order, with unknown items at the end sorted by value
|
||
function sortBySubcategoryOrder(items, parentCategory, nameGetter = (item) => item.name) {
|
||
const order = subcategoryOrder[parentCategory] || [];
|
||
return [...items].sort((a, b) => {
|
||
const aName = nameGetter(a);
|
||
const bName = nameGetter(b);
|
||
const aIndex = order.indexOf(aName);
|
||
const bIndex = order.indexOf(bName);
|
||
|
||
// Both in predefined order - sort by that order
|
||
if (aIndex !== -1 && bIndex !== -1) {
|
||
return aIndex - bIndex;
|
||
}
|
||
// Only a is in predefined order - a comes first
|
||
if (aIndex !== -1) return -1;
|
||
// Only b is in predefined order - b comes first
|
||
if (bIndex !== -1) return 1;
|
||
// Neither in predefined order - sort by value descending
|
||
const aValue = typeof a === 'object' ? (a.value || 0) : 0;
|
||
const bValue = typeof b === 'object' ? (b.value || 0) : 0;
|
||
return bValue - aValue;
|
||
});
|
||
}
|
||
|
||
// 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(from 90deg, #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(from 90deg, ${gradientStops.join(', ')})`;
|
||
}
|
||
|
||
// Find category data at a given path in month's transaction data
|
||
function findCategoryDataAtPath(monthData, path) {
|
||
if (!path || path.length === 0) {
|
||
return null; // No drill-down, use full data
|
||
}
|
||
|
||
// Group transactions by the hierarchy at this path level
|
||
const categoryName = path[0];
|
||
|
||
// Filter transactions belonging to this category
|
||
const filteredTransactions = monthData.filter(item => item.category === categoryName);
|
||
|
||
if (filteredTransactions.length === 0) {
|
||
return { empty: true }; // Category doesn't exist in this month
|
||
}
|
||
|
||
if (path.length === 1) {
|
||
// Return subcategory breakdown
|
||
return { transactions: filteredTransactions, level: 'subcategory' };
|
||
}
|
||
|
||
// Path length >= 2, filter by subcategory
|
||
const subcategoryName = path[1];
|
||
const subFiltered = filteredTransactions.filter(item => item.subcategory === subcategoryName);
|
||
|
||
if (subFiltered.length === 0) {
|
||
return { empty: true };
|
||
}
|
||
|
||
if (path.length === 2) {
|
||
// Return microcategory breakdown
|
||
return { transactions: subFiltered, level: 'microcategory' };
|
||
}
|
||
|
||
// Path length >= 3, filter by microcategory
|
||
const microcategoryName = path[2];
|
||
const microFiltered = subFiltered.filter(item => item.microcategory === microcategoryName);
|
||
|
||
if (microFiltered.length === 0) {
|
||
return { empty: true };
|
||
}
|
||
|
||
// Return transaction-level breakdown
|
||
return { transactions: microFiltered, level: 'transaction' };
|
||
}
|
||
|
||
// Get the base color for a drill-down path
|
||
function getPathBaseColor(path) {
|
||
if (!path || path.length === 0) {
|
||
return categoryColors[0];
|
||
}
|
||
|
||
const categoryName = path[0];
|
||
const categoryIndex = categoryOrder.indexOf(categoryName);
|
||
|
||
if (categoryIndex !== -1) {
|
||
return categoryColors[categoryIndex];
|
||
}
|
||
|
||
return categoryColors[0];
|
||
}
|
||
|
||
// Generate gradient for a month at the current drill-down path
|
||
function generateDrilledDownGradient(monthData, path) {
|
||
const result = findCategoryDataAtPath(monthData, path);
|
||
|
||
// No drill-down - use original function
|
||
if (!result) {
|
||
return generateMonthPreviewGradient(monthData);
|
||
}
|
||
|
||
// Empty state - category doesn't exist
|
||
if (result.empty) {
|
||
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
|
||
}
|
||
|
||
const { transactions, level } = result;
|
||
|
||
// Group by the appropriate field based on level
|
||
const groupField = level === 'subcategory' ? 'subcategory'
|
||
: level === 'microcategory' ? 'microcategory'
|
||
: 'simple_name';
|
||
|
||
const totals = {};
|
||
let total = 0;
|
||
|
||
transactions.forEach(item => {
|
||
const key = item[groupField] || '(без категории)';
|
||
const amount = Math.abs(parseFloat(item.amount_rub));
|
||
if (!isNaN(amount)) {
|
||
totals[key] = (totals[key] || 0) + amount;
|
||
total += amount;
|
||
}
|
||
});
|
||
|
||
if (total === 0) {
|
||
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
|
||
}
|
||
|
||
// Get the parent category for color lookup and ordering
|
||
const parentCategory = path.length > 0 ? path[0] : null;
|
||
|
||
// Sort by fixed subcategory order (for first level), fallback to value for unknown items
|
||
const order = subcategoryOrder[parentCategory] || [];
|
||
const sortedKeys = Object.keys(totals).sort((a, b) => {
|
||
const aIndex = order.indexOf(a);
|
||
const bIndex = order.indexOf(b);
|
||
|
||
// Both in predefined order - sort by that order
|
||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||
// Only a is in predefined order - a comes first
|
||
if (aIndex !== -1) return -1;
|
||
// Only b is in predefined order - b comes first
|
||
if (bIndex !== -1) return 1;
|
||
// Neither in predefined order - sort by value descending
|
||
return totals[b] - totals[a];
|
||
});
|
||
|
||
const gradientStops = [];
|
||
let currentAngle = 0;
|
||
const predefinedOrder = parentCategory ? (subcategoryOrder[parentCategory] || []) : [];
|
||
|
||
let unknownCount = 0;
|
||
sortedKeys.forEach((key, index) => {
|
||
const percentage = totals[key] / total;
|
||
const angle = percentage * 360;
|
||
// Use fixed subcategory colors for first level, fallback to palette for deeper levels
|
||
let color;
|
||
if (level === 'subcategory' && parentCategory) {
|
||
const isUnknown = !predefinedOrder.includes(key);
|
||
color = getSubcategoryColor(parentCategory, key, index, isUnknown ? unknownCount++ : null);
|
||
} else {
|
||
color = defaultColorPalette[index % defaultColorPalette.length];
|
||
}
|
||
|
||
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
|
||
currentAngle += angle;
|
||
});
|
||
|
||
return `conic-gradient(from 90deg, ${gradientStops.join(', ')})`;
|
||
}
|
||
|
||
// Update all month preview gradients based on current drill path
|
||
function updateAllMonthPreviews() {
|
||
const buttons = document.querySelectorAll('.month-btn');
|
||
|
||
buttons.forEach(btn => {
|
||
const month = btn.dataset.month;
|
||
const preview = btn.querySelector('.month-preview');
|
||
|
||
if (preview && monthDataCache[month]) {
|
||
preview.style.background = generateDrilledDownGradient(monthDataCache[month], currentDrillPath);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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
|
||
await loadTinyColor();
|
||
|
||
// Fetch available months from server
|
||
const response = await fetch('/api/months');
|
||
availableMonths = await response.json();
|
||
|
||
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 = '';
|
||
|
||
// 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 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', (event) => {
|
||
if (event.metaKey || event.ctrlKey) {
|
||
// Cmd/Ctrl+click: toggle individual month
|
||
toggleMonthSelection(index);
|
||
} else if (event.shiftKey) {
|
||
// Shift+click: select interval from current to clicked
|
||
selectMonthInterval(index);
|
||
} else {
|
||
selectSingleMonth(index);
|
||
}
|
||
});
|
||
monthList.appendChild(btn);
|
||
});
|
||
|
||
// Restore previous month selection when possible, otherwise default to latest month
|
||
if (restoreMonthSelectionState()) {
|
||
if (selectedMonthIndices.size > 1) {
|
||
await renderSelectedMonths();
|
||
} else {
|
||
await selectMonth(currentMonthIndex);
|
||
}
|
||
} else {
|
||
currentMonthIndex = availableMonths.length - 1;
|
||
selectedMonthIndices.clear();
|
||
selectedMonthIndices.add(currentMonthIndex);
|
||
await selectMonth(currentMonthIndex);
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Set up arrow button handlers
|
||
document.getElementById('prev-month').addEventListener('click', (event) => {
|
||
if (currentMonthIndex > 0) {
|
||
if (event.metaKey || event.ctrlKey) {
|
||
// Cmd/Ctrl+arrow: toggle previous month
|
||
toggleMonthSelection(currentMonthIndex - 1);
|
||
} else if (event.shiftKey) {
|
||
// Shift+arrow: extend selection to previous month
|
||
selectMonthInterval(currentMonthIndex - 1);
|
||
} else {
|
||
selectSingleMonth(currentMonthIndex - 1);
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('next-month').addEventListener('click', (event) => {
|
||
if (currentMonthIndex < availableMonths.length - 1) {
|
||
if (event.metaKey || event.ctrlKey) {
|
||
// Cmd/Ctrl+arrow: toggle next month
|
||
toggleMonthSelection(currentMonthIndex + 1);
|
||
} else if (event.shiftKey) {
|
||
// Shift+arrow: extend selection to next month
|
||
selectMonthInterval(currentMonthIndex + 1);
|
||
} else {
|
||
selectSingleMonth(currentMonthIndex + 1);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Set up home icon click handler (go back to root)
|
||
const homeIcon = document.querySelector('.center-home');
|
||
if (homeIcon) {
|
||
homeIcon.addEventListener('click', () => {
|
||
if (historyIndex > 0) {
|
||
history.back();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Set up view switcher
|
||
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const target = btn.dataset.view;
|
||
if (target !== currentView) {
|
||
history.pushState(target === 'timeline' ? { timelineDrill: timelineDrillPath } : { monthView: true }, '');
|
||
}
|
||
switchView(target);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Transform children data for drill-down (extracted from click handler)
|
||
function transformDrillDownData(parentNode, parentCategoryName) {
|
||
const newData = [];
|
||
const sortedChildren = sortBySubcategoryOrder(parentNode.children, parentCategoryName);
|
||
const predefinedOrder = subcategoryOrder[parentCategoryName] || [];
|
||
|
||
let unknownCount = 0;
|
||
sortedChildren.forEach((child, i) => {
|
||
const isUnknown = !predefinedOrder.includes(child.name);
|
||
const color = getSubcategoryColor(parentCategoryName, child.name, i, isUnknown ? unknownCount++ : null);
|
||
const newCategory = {
|
||
name: child.name,
|
||
value: child.value,
|
||
transactions: child.transactions,
|
||
itemStyle: { color: color },
|
||
children: []
|
||
};
|
||
|
||
if (child.children && child.children.length > 0) {
|
||
const sortedMicros = [...child.children].sort((a, b) => b.value - a.value);
|
||
const microColors = generateColorGradient(color, sortedMicros.length);
|
||
|
||
sortedMicros.forEach((micro, j) => {
|
||
const microCategory = {
|
||
name: micro.name,
|
||
value: micro.value,
|
||
transactions: micro.transactions,
|
||
itemStyle: { color: microColors[j] },
|
||
children: []
|
||
};
|
||
|
||
if (micro.transactions && micro.transactions.length > 0) {
|
||
const sortedTransactions = [...micro.transactions].sort((a, b) => b.value - a.value);
|
||
const transactionColors = generateColorGradient(microColors[j], sortedTransactions.length);
|
||
|
||
sortedTransactions.forEach((transaction, k) => {
|
||
microCategory.children.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
itemStyle: { color: transactionColors[k] },
|
||
originalRow: transaction.originalRow
|
||
});
|
||
});
|
||
}
|
||
|
||
newCategory.children.push(microCategory);
|
||
});
|
||
}
|
||
|
||
if (child.transactions) {
|
||
const transactionsWithoutMicro = child.transactions.filter(t => t.displayMicrocategory === '');
|
||
if (transactionsWithoutMicro.length > 0) {
|
||
const transactionGroups = {};
|
||
transactionsWithoutMicro.forEach(t => {
|
||
if (!transactionGroups[t.name]) {
|
||
transactionGroups[t.name] = { name: t.name, value: 0, transactions: [] };
|
||
}
|
||
transactionGroups[t.name].value += t.value;
|
||
transactionGroups[t.name].transactions.push(t);
|
||
});
|
||
|
||
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
||
const transactionColors = generateColorGradient(color, groups.length);
|
||
|
||
groups.forEach((group, j) => {
|
||
const transactionCategory = {
|
||
name: group.name,
|
||
value: group.value,
|
||
transactions: group.transactions,
|
||
itemStyle: { color: transactionColors[j] },
|
||
children: []
|
||
};
|
||
|
||
if (group.transactions.length > 0) {
|
||
const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value);
|
||
const individualColors = generateColorGradient(transactionColors[j], sortedTransactions.length);
|
||
|
||
sortedTransactions.forEach((transaction, k) => {
|
||
transactionCategory.children.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
itemStyle: { color: individualColors[k] },
|
||
originalRow: transaction.originalRow
|
||
});
|
||
});
|
||
}
|
||
|
||
newCategory.children.push(transactionCategory);
|
||
});
|
||
}
|
||
}
|
||
|
||
newData.push(newCategory);
|
||
});
|
||
|
||
return newData;
|
||
}
|
||
|
||
// Transform transaction-only data for drill-down
|
||
function transformTransactionData(node) {
|
||
const colorPalette = [
|
||
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
|
||
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
|
||
];
|
||
|
||
const transactionGroups = {};
|
||
node.transactions.forEach(t => {
|
||
if (!transactionGroups[t.name]) {
|
||
transactionGroups[t.name] = { name: t.name, value: 0, transactions: [] };
|
||
}
|
||
transactionGroups[t.name].value += t.value;
|
||
transactionGroups[t.name].transactions.push(t);
|
||
});
|
||
|
||
const newData = [];
|
||
const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value);
|
||
|
||
groups.forEach((group, i) => {
|
||
const color = colorPalette[i % colorPalette.length];
|
||
const transactionCategory = {
|
||
name: group.name,
|
||
value: group.value,
|
||
transactions: group.transactions,
|
||
itemStyle: { color: color },
|
||
children: []
|
||
};
|
||
|
||
if (group.transactions.length > 0) {
|
||
const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value);
|
||
const transactionColors = generateColorGradient(color, sortedTransactions.length);
|
||
|
||
sortedTransactions.forEach((transaction, j) => {
|
||
transactionCategory.children.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
itemStyle: { color: transactionColors[j] },
|
||
originalRow: transaction.originalRow
|
||
});
|
||
});
|
||
}
|
||
|
||
newData.push(transactionCategory);
|
||
});
|
||
|
||
return newData;
|
||
}
|
||
|
||
// Navigate to a path in the given sunburst data, returns drilled data or null if path not found
|
||
function navigateToPath(sunburstData, path) {
|
||
if (!path || path.length === 0) {
|
||
return null; // Stay at root
|
||
}
|
||
|
||
// Try to find each level of the path
|
||
let currentData = sunburstData.data;
|
||
let currentTotal = sunburstData.total;
|
||
let currentName = null;
|
||
let validPath = [];
|
||
|
||
for (let i = 0; i < path.length; i++) {
|
||
const targetName = path[i];
|
||
const found = currentData.find(item => item.name === targetName);
|
||
|
||
if (!found) {
|
||
// Path level not found, return what we have so far
|
||
break;
|
||
}
|
||
|
||
validPath.push(targetName);
|
||
currentName = targetName;
|
||
currentTotal = found.value;
|
||
|
||
// Transform this level's children to become top-level (same as click handler logic)
|
||
if (found.children && found.children.length > 0) {
|
||
currentData = transformDrillDownData(found, targetName);
|
||
} else if (found.transactions && found.transactions.length > 0) {
|
||
currentData = transformTransactionData(found);
|
||
} else {
|
||
// No more levels to drill into
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (validPath.length === 0) {
|
||
return null; // Couldn't match any part of path
|
||
}
|
||
|
||
return {
|
||
data: currentData,
|
||
total: currentTotal,
|
||
contextName: currentName,
|
||
path: validPath
|
||
};
|
||
}
|
||
|
||
// Toggle a month in/out of multi-selection (Cmd/Ctrl+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();
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Select a single month (normal click behavior)
|
||
async function selectSingleMonth(index) {
|
||
selectedMonthIndices.clear();
|
||
selectedMonthIndices.add(index);
|
||
await selectMonth(index);
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Select interval from current month to target (Shift+click behavior)
|
||
async function selectMonthInterval(targetIndex) {
|
||
const start = Math.min(currentMonthIndex, targetIndex);
|
||
const end = Math.max(currentMonthIndex, targetIndex);
|
||
|
||
selectedMonthIndices.clear();
|
||
for (let i = start; i <= end; i++) {
|
||
selectedMonthIndices.add(i);
|
||
}
|
||
currentMonthIndex = targetIndex;
|
||
await renderSelectedMonths();
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// 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);
|
||
const savedPath = [...currentDrillPath];
|
||
|
||
// 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();
|
||
|
||
// Try to restore the same drill-down path in merged view
|
||
const navigatedState = navigateToPath(sunburstData, savedPath);
|
||
|
||
let targetData, targetTotal, targetName, targetPath;
|
||
|
||
if (navigatedState) {
|
||
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
|
||
targetData = navigatedState.data;
|
||
targetTotal = navigatedState.total;
|
||
targetName = navigatedState.contextName;
|
||
targetPath = navigatedState.path;
|
||
saveToHistory(targetData, targetTotal, targetName, false, targetPath);
|
||
} else {
|
||
targetData = sunburstData.data;
|
||
targetTotal = sunburstData.total;
|
||
targetName = null;
|
||
targetPath = [];
|
||
saveToHistory(targetData, targetTotal, targetName, true, targetPath);
|
||
}
|
||
|
||
// Update the chart
|
||
if (option && option.series && option.series.data) {
|
||
option.series.data = targetData;
|
||
|
||
myChart.setOption({
|
||
series: [{
|
||
type: 'sunburst',
|
||
data: targetData,
|
||
layoutAnimation: true,
|
||
animationDuration: 500,
|
||
animationEasing: 'cubicInOut'
|
||
}]
|
||
}, {
|
||
lazyUpdate: false,
|
||
silent: false
|
||
});
|
||
|
||
updateCenterLabel(monthLabel, targetTotal, targetName);
|
||
setupHoverEvents({ total: targetTotal, data: targetData }, targetName);
|
||
updateAllMonthPreviews();
|
||
} else {
|
||
// Initial render
|
||
renderChart(mergedData);
|
||
}
|
||
|
||
// Scroll to show the current navigation month
|
||
const activeBtn = document.querySelector('.month-btn.active');
|
||
if (activeBtn) {
|
||
activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||
}
|
||
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Select and load a specific month
|
||
async function selectMonth(index) {
|
||
const month = availableMonths[index];
|
||
|
||
// If clicking on the already-selected month while drilled down, reset to root
|
||
if (index === currentMonthIndex && currentDrillPath.length > 0 && drillDownHistory.length > 0) {
|
||
goToRoot();
|
||
return;
|
||
}
|
||
|
||
currentMonthIndex = 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 = monthDataCache[month] || 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 the module-level original data for center-click reset
|
||
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
|
||
|
||
// Save the current drill path before modifying state
|
||
const savedPath = [...currentDrillPath];
|
||
|
||
// Reset history for the new month
|
||
resetHistory();
|
||
|
||
// Try to navigate to the same path in the new month
|
||
const navigatedState = navigateToPath(sunburstData, savedPath);
|
||
|
||
let targetData, targetTotal, targetName, targetPath;
|
||
|
||
if (navigatedState) {
|
||
// Successfully navigated to (part of) the path
|
||
// First save the root state so we can go back to it
|
||
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
|
||
|
||
targetData = navigatedState.data;
|
||
targetTotal = navigatedState.total;
|
||
targetName = navigatedState.contextName;
|
||
targetPath = navigatedState.path;
|
||
|
||
// Save the drilled-down state
|
||
saveToHistory(targetData, targetTotal, targetName, false, targetPath);
|
||
} else {
|
||
// Stay at root level
|
||
targetData = sunburstData.data;
|
||
targetTotal = sunburstData.total;
|
||
targetName = null;
|
||
targetPath = [];
|
||
|
||
// Save initial state
|
||
saveToHistory(targetData, targetTotal, targetName, true, targetPath);
|
||
}
|
||
|
||
// Update the data
|
||
option.series.data = targetData;
|
||
|
||
// Update the total amount in the center text
|
||
const russianMonth = getRussianMonthName(month);
|
||
|
||
myChart.setOption({
|
||
series: [{
|
||
type: 'sunburst',
|
||
data: targetData,
|
||
layoutAnimation: true,
|
||
animationDuration: 500,
|
||
animationEasing: 'cubicInOut'
|
||
}]
|
||
}, {
|
||
lazyUpdate: false,
|
||
silent: false
|
||
});
|
||
|
||
updateCenterLabel(russianMonth, targetTotal, targetName);
|
||
|
||
// Update hover events
|
||
setupHoverEvents({ total: targetTotal, data: targetData }, targetName);
|
||
|
||
// Update mini-chart previews
|
||
updateAllMonthPreviews();
|
||
} 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' });
|
||
}
|
||
|
||
saveMonthSelectionState();
|
||
}
|
||
|
||
// Update month navigator UI state
|
||
function updateMonthNavigator() {
|
||
// Update button active states
|
||
const buttons = document.querySelectorAll('.month-btn');
|
||
|
||
buttons.forEach((btn, index) => {
|
||
if (selectedMonthIndices.has(index)) {
|
||
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
|
||
async function initVisualization() {
|
||
await loadAvailableMonths();
|
||
|
||
// Ensure the chart is properly sized on initial load
|
||
myChart.resize();
|
||
}
|
||
|
||
// Start the application
|
||
initVisualization();
|
||
|
||
// Handle window resize
|
||
let _resizeDebounceTimer = null;
|
||
window.addEventListener('resize', function() {
|
||
if (currentView === 'month') {
|
||
adjustChartSize();
|
||
}
|
||
myChart.resize();
|
||
|
||
// Re-render timeline when crossing the mobile breakpoint
|
||
if (currentView === 'timeline') {
|
||
const nowMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||
if (nowMobile !== _lastTimelineMobile) {
|
||
_lastTimelineMobile = nowMobile;
|
||
clearTimeout(_resizeDebounceTimer);
|
||
_resizeDebounceTimer = setTimeout(function() {
|
||
const currentOption = myChart.getOption();
|
||
const legendSelected = currentOption && currentOption.legend
|
||
&& currentOption.legend[0] && currentOption.legend[0].selected
|
||
? currentOption.legend[0].selected
|
||
: null;
|
||
renderTimelineChart(timelineDrillPath, 'replace', legendSelected);
|
||
}, 150);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Function to adjust chart size based on screen width
|
||
function adjustChartSize() {
|
||
// Check if option is defined
|
||
if (!option) return;
|
||
|
||
const screenWidth = window.innerWidth;
|
||
const isMobile = screenWidth <= 500;
|
||
const isLandscapePhone = window.innerHeight < window.innerWidth && window.innerHeight <= 500;
|
||
const isCompact = isMobile || isLandscapePhone;
|
||
|
||
// Compact mode (portrait phones + landscape phones): extend layers to fill container
|
||
// Hole stays at 20%, layers scaled to fill remaining 80% (vs 55% on desktop)
|
||
const level1Inner = '20%';
|
||
const level1Outer = isCompact ? '56%' : '45%';
|
||
const level2Outer = isCompact ? '93%' : '70%';
|
||
const level3Outer = isCompact ? '100%' : '75%';
|
||
const outerRadius = isCompact ? '100%' : '95%';
|
||
|
||
// Smaller font sizes on compact, bolder for readability
|
||
const level1FontSize = isCompact ? 10 : 13;
|
||
const level1LineHeight = isCompact ? 12 : 15;
|
||
const level1FontWeight = isCompact ? 600 : 500;
|
||
const level2FontSize = isCompact ? 9 : 11;
|
||
const level2FontWeight = isCompact ? 600 : 400;
|
||
|
||
// Update layer proportions
|
||
option.series.levels[1].r0 = level1Inner;
|
||
option.series.levels[1].r = level1Outer;
|
||
option.series.levels[2].r0 = level1Outer;
|
||
option.series.levels[2].r = level2Outer;
|
||
option.series.levels[3].r0 = level2Outer;
|
||
option.series.levels[3].r = level3Outer;
|
||
option.series.radius = [0, outerRadius];
|
||
|
||
// Update font sizes and weights
|
||
option.series.levels[1].label.fontSize = level1FontSize;
|
||
option.series.levels[1].label.lineHeight = level1LineHeight;
|
||
option.series.levels[1].label.fontWeight = level1FontWeight;
|
||
option.series.levels[2].label.fontSize = level2FontSize;
|
||
option.series.levels[2].label.fontWeight = level2FontWeight;
|
||
|
||
// Update level 3 labels: hide on compact/small screens, show on desktop
|
||
if (isCompact) {
|
||
option.series.levels[3].label.show = false;
|
||
option.series.levels[3].label.position = 'inside';
|
||
} else if (screenWidth < 950) {
|
||
option.series.levels[3].label.show = false;
|
||
option.series.levels[3].label.position = 'outside';
|
||
} else {
|
||
option.series.levels[3].label.show = function(param) {
|
||
return param.percent > 0.000;
|
||
};
|
||
option.series.levels[3].label.position = 'outside';
|
||
}
|
||
|
||
// Calculate center position
|
||
let centerPosition;
|
||
if (screenWidth <= 850 || isCompact) {
|
||
// Stacked layout or compact landscape: center the chart
|
||
centerPosition = 50;
|
||
} else if (screenWidth >= 1000) {
|
||
centerPosition = 50;
|
||
} else {
|
||
// Gradual transition between 850-1000px (side-by-side layout)
|
||
const transitionProgress = (screenWidth - 850) / 150;
|
||
centerPosition = 40 + (transitionProgress * 10);
|
||
}
|
||
|
||
// Update chart center position
|
||
option.series.center = [`${centerPosition}%`, '50%'];
|
||
|
||
myChart.setOption(option);
|
||
}
|
||
|
||
// Add mouseover handler to update details box
|
||
function setupHoverEvents(sunburstData, contextName = null) {
|
||
const topItemsElement = document.getElementById('top-items');
|
||
|
||
// Create a container for details header with name and amount
|
||
const detailsHeader = document.getElementById('details-header') || document.createElement('div');
|
||
detailsHeader.id = 'details-header';
|
||
detailsHeader.className = 'details-header';
|
||
|
||
if (!document.getElementById('details-header')) {
|
||
const detailsBox = document.querySelector('.details-box');
|
||
detailsBox.insertBefore(detailsHeader, detailsBox.firstChild);
|
||
}
|
||
|
||
// Variables to track the circular boundary - will be recalculated when needed
|
||
let chartCenterX, chartCenterY, chartRadius;
|
||
|
||
// Function to recalculate chart center and radius for hover detection
|
||
function recalculateChartBoundary() {
|
||
const chartCenter = option.series.center;
|
||
chartCenterX = chartDom.offsetWidth * (parseFloat(chartCenter[0]) / 100);
|
||
chartCenterY = chartDom.offsetHeight * (parseFloat(chartCenter[1]) / 100);
|
||
chartRadius = Math.min(chartDom.offsetWidth, chartDom.offsetHeight) *
|
||
(parseFloat(option.series.radius[1]) / 100);
|
||
}
|
||
|
||
// Initial calculation of chart boundary
|
||
recalculateChartBoundary();
|
||
|
||
// Update chart boundary on resize
|
||
window.addEventListener('resize', recalculateChartBoundary);
|
||
|
||
// Check if point is inside the sunburst chart circle
|
||
function isInsideChart(x, y) {
|
||
const dx = x - chartCenterX;
|
||
const dy = y - chartCenterY;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
return distance <= chartRadius;
|
||
}
|
||
|
||
// Floating eye button for chart
|
||
const chartEyeBtn = document.getElementById('chart-eye-btn');
|
||
let currentHoveredSectorData = null;
|
||
let hideButtonTimeout = null;
|
||
let isOverEyeButton = false;
|
||
let isOverChartSector = false; // Track if mouse is over any chart sector
|
||
let lastMouseX = 0;
|
||
let lastMouseY = 0;
|
||
let mouseMovingTimeout = null; // Timer to detect mouse inactivity
|
||
let isMouseMoving = false; // Track if mouse is actively moving
|
||
|
||
// Calculate the center angle of a sector based on its data
|
||
function getSectorLayoutFromChart(params) {
|
||
// Use ECharts internal data model to get actual rendered layout
|
||
try {
|
||
const seriesModel = myChart.getModel().getSeriesByIndex(0);
|
||
if (!seriesModel) return null;
|
||
|
||
const data = seriesModel.getData();
|
||
if (!data) return null;
|
||
|
||
// Get the layout for this specific data item
|
||
const layout = data.getItemLayout(params.dataIndex);
|
||
if (layout) {
|
||
return {
|
||
startAngle: layout.startAngle,
|
||
endAngle: layout.endAngle,
|
||
r: layout.r, // outer radius
|
||
r0: layout.r0, // inner radius
|
||
cx: layout.cx, // center x
|
||
cy: layout.cy // center y
|
||
};
|
||
}
|
||
} catch (e) {
|
||
console.log('Could not get layout from chart model:', e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Track current hovered sector for persistent button display
|
||
let currentHoveredSectorParams = null;
|
||
let pendingButtonPosition = null; // Store button position when mouse stops
|
||
|
||
// Update button visibility based on mouse movement state
|
||
function updateButtonVisibility() {
|
||
if (!chartEyeBtn) return;
|
||
|
||
// Show button if mouse is moving OR hovering over the button itself
|
||
if ((isMouseMoving || isOverEyeButton) && pendingButtonPosition) {
|
||
chartEyeBtn.style.left = pendingButtonPosition.left;
|
||
chartEyeBtn.style.top = pendingButtonPosition.top;
|
||
chartEyeBtn.style.transform = pendingButtonPosition.transform;
|
||
chartEyeBtn.style.opacity = '1';
|
||
chartEyeBtn.style.pointerEvents = 'auto';
|
||
} else if (!isOverEyeButton) {
|
||
chartEyeBtn.style.opacity = '0';
|
||
chartEyeBtn.style.pointerEvents = 'none';
|
||
}
|
||
}
|
||
|
||
// Handle mouse movement on chart - show button while moving, hide after 0.5s of inactivity
|
||
function onChartMouseMove() {
|
||
isMouseMoving = true;
|
||
updateButtonVisibility();
|
||
|
||
// Clear existing timeout
|
||
if (mouseMovingTimeout) {
|
||
clearTimeout(mouseMovingTimeout);
|
||
}
|
||
|
||
// Set timeout to hide button after 0.5 second of no movement
|
||
mouseMovingTimeout = setTimeout(() => {
|
||
isMouseMoving = false;
|
||
updateButtonVisibility();
|
||
}, 500);
|
||
}
|
||
|
||
// Add mousemove listener to chart container
|
||
chartDom.addEventListener('mousemove', onChartMouseMove);
|
||
|
||
// Position and show the floating eye button near the hovered sector
|
||
function showChartEyeButton(params) {
|
||
if (!chartEyeBtn || !params.event) return;
|
||
|
||
// Clear any pending hide timeout
|
||
if (hideButtonTimeout) {
|
||
clearTimeout(hideButtonTimeout);
|
||
hideButtonTimeout = null;
|
||
}
|
||
|
||
currentHoveredSectorData = params.data;
|
||
currentHoveredSectorParams = params;
|
||
isOverChartSector = true;
|
||
|
||
// Get actual layout from ECharts internal model
|
||
const layout = getSectorLayoutFromChart(params);
|
||
|
||
if (layout && layout.cx !== undefined) {
|
||
// Calculate mid-angle of the sector
|
||
const midAngle = (layout.startAngle + layout.endAngle) / 2;
|
||
|
||
// Normalize angle to determine which side of the chart
|
||
let normalizedAngle = midAngle % (2 * Math.PI);
|
||
if (normalizedAngle < 0) normalizedAngle += 2 * Math.PI;
|
||
|
||
// Left side: angle between π/2 and 3π/2 (90° to 270°)
|
||
// On left side, labels read from edge to center, so button goes near inner edge
|
||
// On right side, labels read from center to edge, so button goes near outer edge
|
||
const isLeftSide = normalizedAngle > Math.PI / 2 && normalizedAngle < 3 * Math.PI / 2;
|
||
|
||
// Position button at the appropriate edge based on label reading direction
|
||
const buttonRadius = isLeftSide
|
||
? layout.r0 + 15 // Inner edge for left side (labels read inward)
|
||
: layout.r - 15; // Outer edge for right side (labels read outward)
|
||
|
||
// Calculate button position at the sector's mid-angle
|
||
const buttonX = layout.cx + buttonRadius * Math.cos(midAngle);
|
||
const buttonY = layout.cy + buttonRadius * Math.sin(midAngle);
|
||
|
||
// Calculate rotation for the icon to match sector orientation
|
||
let rotationDeg = (midAngle * 180 / Math.PI);
|
||
if (isLeftSide) {
|
||
rotationDeg += 180;
|
||
}
|
||
|
||
// Store the button position
|
||
pendingButtonPosition = {
|
||
left: (buttonX - 12) + 'px',
|
||
top: (buttonY - 12) + 'px',
|
||
transform: `rotate(${rotationDeg}deg)`
|
||
};
|
||
|
||
// Only show if mouse is moving or hovering over button
|
||
updateButtonVisibility();
|
||
} else {
|
||
// Fallback: clear pending position if we can't get layout
|
||
pendingButtonPosition = null;
|
||
chartEyeBtn.style.opacity = '0';
|
||
chartEyeBtn.style.pointerEvents = 'none';
|
||
}
|
||
}
|
||
|
||
// Hide the floating eye button with a delay
|
||
function hideChartEyeButton(force = false) {
|
||
// Don't hide if mouse is over the button
|
||
if (isOverEyeButton && !force) return;
|
||
|
||
// Clear existing timeout
|
||
if (hideButtonTimeout) {
|
||
clearTimeout(hideButtonTimeout);
|
||
}
|
||
|
||
// Use longer delay if mouse is still in chart area (gives time for sector re-hover)
|
||
const stillInChart = isInsideChart(lastMouseX, lastMouseY);
|
||
const delay = stillInChart ? 400 : 200;
|
||
|
||
hideButtonTimeout = setTimeout(() => {
|
||
// Double-check: don't hide if now over button or still over a chart sector
|
||
if (!isOverEyeButton && !isOverChartSector && chartEyeBtn) {
|
||
chartEyeBtn.style.opacity = '0';
|
||
chartEyeBtn.style.pointerEvents = 'none';
|
||
currentHoveredSectorData = null;
|
||
currentHoveredSectorParams = null;
|
||
}
|
||
}, delay);
|
||
}
|
||
|
||
// Handle click on floating eye button
|
||
if (chartEyeBtn) {
|
||
chartEyeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (currentHoveredSectorData) {
|
||
openTransactionModal(currentHoveredSectorData);
|
||
// Hide button after opening modal
|
||
chartEyeBtn.style.opacity = '0';
|
||
chartEyeBtn.style.pointerEvents = 'none';
|
||
isOverEyeButton = false;
|
||
}
|
||
});
|
||
|
||
// Track when mouse is over the button
|
||
chartEyeBtn.addEventListener('mouseenter', () => {
|
||
isOverEyeButton = true;
|
||
isInsideSection = true; // Keep details panel showing sector info
|
||
if (hideButtonTimeout) {
|
||
clearTimeout(hideButtonTimeout);
|
||
hideButtonTimeout = null;
|
||
}
|
||
// Keep the sector highlighted while hovering over eye button
|
||
if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) {
|
||
myChart.dispatchAction({
|
||
type: 'highlight',
|
||
seriesIndex: 0,
|
||
dataIndex: currentHoveredSectorParams.dataIndex
|
||
});
|
||
}
|
||
});
|
||
|
||
chartEyeBtn.addEventListener('mouseleave', (e) => {
|
||
isOverEyeButton = false;
|
||
|
||
// Check if we're still in the chart area
|
||
const rect = chartDom.getBoundingClientRect();
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
|
||
if (isInsideChart(mouseX, mouseY)) {
|
||
// Still in chart - wait longer for ECharts to potentially re-trigger mouseover
|
||
// The delay in hideChartEyeButton will handle this
|
||
isOverChartSector = true; // Assume still over sector until proven otherwise
|
||
} else {
|
||
// Leaving chart area - remove highlight
|
||
if (currentHoveredSectorParams && currentHoveredSectorParams.dataIndex !== undefined) {
|
||
myChart.dispatchAction({
|
||
type: 'downplay',
|
||
seriesIndex: 0,
|
||
dataIndex: currentHoveredSectorParams.dataIndex
|
||
});
|
||
}
|
||
}
|
||
|
||
hideChartEyeButton();
|
||
});
|
||
}
|
||
|
||
// Function to display items in the details box
|
||
function displayDetailsItems(items, parentValue) {
|
||
// Clear previous top items
|
||
topItemsElement.innerHTML = '';
|
||
|
||
// If we have items to show
|
||
if (items.length > 0) {
|
||
// Create elements for each item
|
||
items.forEach(item => {
|
||
const itemDiv = document.createElement('div');
|
||
itemDiv.className = 'top-item';
|
||
|
||
const nameSpan = document.createElement('span');
|
||
nameSpan.className = 'top-item-name';
|
||
|
||
// Add colored circle
|
||
const colorCircle = document.createElement('span');
|
||
colorCircle.className = 'color-circle';
|
||
|
||
// Use the item's color if available, otherwise use a default
|
||
if (item.itemStyle && item.itemStyle.color) {
|
||
colorCircle.style.backgroundColor = item.itemStyle.color;
|
||
} else if (item.color) {
|
||
colorCircle.style.backgroundColor = item.color;
|
||
} else {
|
||
colorCircle.style.backgroundColor = '#cccccc';
|
||
}
|
||
|
||
nameSpan.appendChild(colorCircle);
|
||
nameSpan.appendChild(document.createTextNode(item.name));
|
||
itemDiv.appendChild(nameSpan);
|
||
|
||
// Add percentage after name if we have a parent value
|
||
if (parentValue) {
|
||
const percentSpan = document.createElement('span');
|
||
percentSpan.className = 'top-item-percent';
|
||
const percentage = ((item.value / parentValue) * 100).toFixed(1);
|
||
percentSpan.textContent = percentage + '%';
|
||
itemDiv.appendChild(percentSpan);
|
||
}
|
||
|
||
// Add eye button for viewing transaction details
|
||
const eyeBtn = document.createElement('button');
|
||
eyeBtn.className = 'eye-btn';
|
||
eyeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
eyeBtn.title = 'View transaction details';
|
||
eyeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openTransactionModal(item);
|
||
});
|
||
itemDiv.appendChild(eyeBtn);
|
||
|
||
const amountSpan = document.createElement('span');
|
||
amountSpan.className = 'top-item-amount';
|
||
amountSpan.textContent = formatRUB(item.value) + '\u202F₽';
|
||
itemDiv.appendChild(amountSpan);
|
||
topItemsElement.appendChild(itemDiv);
|
||
});
|
||
} else {
|
||
// No items to show
|
||
topItemsElement.innerHTML = '<div>No details available</div>';
|
||
}
|
||
}
|
||
|
||
// Variable to store currently hovered data for the header eye button
|
||
let currentHoveredData = null;
|
||
|
||
// Helper function to update the details header
|
||
function updateDetailsHeader(name, value, color, data) {
|
||
currentHoveredData = data;
|
||
detailsHeader.innerHTML = `
|
||
<span class="hover-name">
|
||
<span class="color-circle" style="background-color: ${color};"></span>
|
||
${name}
|
||
</span>
|
||
<button class="eye-btn header-eye-btn" title="View transaction details">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
</button>
|
||
<span class="hover-amount">${formatRUB(value)}\u202F₽</span>
|
||
`;
|
||
// Add click handler to header eye button
|
||
const headerEyeBtn = detailsHeader.querySelector('.header-eye-btn');
|
||
if (headerEyeBtn && data) {
|
||
headerEyeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openTransactionModal(data);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Show the default view with top categories
|
||
// Assign to global so it can be called from closeTransactionModal
|
||
showDefaultView = function() {
|
||
// Use context name (drilled-down sector) if provided, otherwise use month name
|
||
const selectedMonth = document.getElementById('month-select').value;
|
||
const monthName = getRussianMonthName(selectedMonth);
|
||
const displayName = contextName || monthName;
|
||
|
||
// Create a virtual "data" object for the total view that contains all transactions
|
||
const allTransactions = [];
|
||
sunburstData.data.forEach(category => {
|
||
if (category.transactions) {
|
||
allTransactions.push(...category.transactions);
|
||
}
|
||
});
|
||
const totalData = { name: displayName, transactions: allTransactions };
|
||
currentHoveredData = totalData;
|
||
|
||
detailsHeader.innerHTML = `
|
||
<span class="hover-name">
|
||
<span class="color-circle" style="background-color: #5470c6;"></span>
|
||
${displayName}
|
||
</span>
|
||
<button class="eye-btn header-eye-btn" title="View all transactions">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||
<circle cx="12" cy="12" r="3"/>
|
||
</svg>
|
||
</button>
|
||
<span class="hover-amount">${formatRUB(sunburstData.total)}\u202F₽</span>
|
||
`;
|
||
|
||
// Add click handler to header eye button
|
||
const headerEyeBtn = detailsHeader.querySelector('.header-eye-btn');
|
||
if (headerEyeBtn) {
|
||
headerEyeBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openTransactionModal(totalData);
|
||
});
|
||
}
|
||
|
||
// Show items - sort by value for root level, use fixed order for drilled-down
|
||
const itemsToDisplay = contextName
|
||
? [...sunburstData.data] // Drilled down: use fixed subcategory order
|
||
: [...sunburstData.data].sort((a, b) => b.value - a.value); // Root level: sort by value
|
||
displayDetailsItems(itemsToDisplay, sunburstData.total);
|
||
}
|
||
|
||
// Show default view initially
|
||
showDefaultView();
|
||
|
||
// Track if we're inside a section to handle sector exit properly
|
||
let isInsideSection = false;
|
||
|
||
// Prevent details panel reset when user is interacting with it (touch devices)
|
||
let isInteractingWithDetails = false;
|
||
const detailsBox = document.getElementById('details-box');
|
||
if (detailsBox) {
|
||
detailsBox.addEventListener('pointerdown', () => {
|
||
isInteractingWithDetails = true;
|
||
});
|
||
// Clear after a short delay to allow click handlers to fire
|
||
detailsBox.addEventListener('pointerup', () => {
|
||
setTimeout(() => { isInteractingWithDetails = false; }, 300);
|
||
});
|
||
}
|
||
|
||
// Add general mousemove event listener to detect when outside chart circle
|
||
chartDom.addEventListener('mousemove', function(e) {
|
||
// Get mouse position relative to chart container
|
||
const rect = chartDom.getBoundingClientRect();
|
||
const mouseX = e.clientX - rect.left;
|
||
const mouseY = e.clientY - rect.top;
|
||
|
||
// Track mouse position for eye button hide logic
|
||
lastMouseX = mouseX;
|
||
lastMouseY = mouseY;
|
||
|
||
// If not inside the chart circle and we were inside a section, show default view
|
||
if (!isInsideChart(mouseX, mouseY) && isInsideSection && !isInteractingWithDetails) {
|
||
isInsideSection = false;
|
||
// Reset details immediately when leaving a section
|
||
showDefaultView();
|
||
}
|
||
});
|
||
|
||
// Add mouseover event listener for sectors
|
||
myChart.on('mouseover', function(params) {
|
||
if (params.data) {
|
||
// Show floating eye button at end of sector label
|
||
showChartEyeButton(params);
|
||
|
||
// Compute depth from treePathInfo instead of using params.depth
|
||
let depth = params.treePathInfo ? params.treePathInfo.length - 1 : 0;
|
||
// Debug log for every mouseover event using computed depth
|
||
console.log('Hovered node:', { depth: depth, name: params.name, transactionsLength: (params.data.transactions ? params.data.transactions.length : 0), childrenLength: (params.data.children ? params.data.children.length : 0) });
|
||
|
||
// Handle subcategory nodes (depth 2): show microcategories and transactions without microcategory
|
||
if (depth === 2) {
|
||
isInsideSection = true;
|
||
console.log('DEBUG: Hovered subcategory node:', params.name, 'Transactions:', params.data.transactions, 'Children:', params.data.children);
|
||
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
||
const MAX_DETAIL_ITEMS = 10;
|
||
let itemsToShow = [];
|
||
if (params.data.children && params.data.children.length > 0) {
|
||
itemsToShow = [...params.data.children];
|
||
}
|
||
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
|
||
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||
// Create a slightly transparent effect by mixing with white
|
||
const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 60).toString();
|
||
const remaining = MAX_DETAIL_ITEMS - itemsToShow.length;
|
||
const transactionsWithoutMicro = (params.data.transactions || [])
|
||
.filter(t => t.displayMicrocategory === '')
|
||
.map(t => ({ ...t, itemStyle: { color: modifiedColor } }));
|
||
itemsToShow = itemsToShow.concat(transactionsWithoutMicro.slice(0, remaining));
|
||
}
|
||
displayDetailsItems(itemsToShow, params.value);
|
||
return;
|
||
}
|
||
// Handle category nodes (depth 1): show subcategories and transactions without microcategory
|
||
if (depth === 1) {
|
||
isInsideSection = true;
|
||
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
||
const MAX_DETAIL_ITEMS = 10;
|
||
let itemsToShow = [];
|
||
if (params.data.children && params.data.children.length > 0) {
|
||
itemsToShow = [...params.data.children];
|
||
}
|
||
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
|
||
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||
// Create a slightly transparent effect by mixing with white
|
||
const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 30).toString();
|
||
const remaining = MAX_DETAIL_ITEMS - itemsToShow.length;
|
||
const transactionsWithoutMicro = (params.data.transactions || [])
|
||
.filter(t => t.displayMicrocategory === '')
|
||
.map(t => ({ ...t, itemStyle: { color: modifiedColor } }));
|
||
itemsToShow = itemsToShow.concat(transactionsWithoutMicro.slice(0, remaining));
|
||
}
|
||
displayDetailsItems(itemsToShow, params.value);
|
||
return;
|
||
}
|
||
// For other depths, continue with existing behaviour
|
||
isInsideSection = true;
|
||
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||
updateDetailsHeader(params.name, params.value, itemColor, params.data);
|
||
const MAX_DETAIL_ITEMS = 10;
|
||
let itemsToShow = [];
|
||
if (params.data.children && params.data.children.length > 0) {
|
||
const sortedChildren = [...params.data.children];
|
||
const allMicrocategories = [];
|
||
const displayedNames = new Set();
|
||
for (const child of sortedChildren) {
|
||
if (child.children && child.children.length > 0) {
|
||
const childMicros = [...child.children].sort((a, b) => b.value - a.value);
|
||
for (const micro of childMicros) {
|
||
if (!displayedNames.has(micro.name)) {
|
||
allMicrocategories.push(micro);
|
||
displayedNames.add(micro.name);
|
||
if (micro.transactions) {
|
||
micro.transactions.forEach(t => {
|
||
displayedNames.add(t.name);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (allMicrocategories.length > 0) {
|
||
itemsToShow = allMicrocategories.slice(0, MAX_DETAIL_ITEMS);
|
||
} else {
|
||
itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS);
|
||
}
|
||
if (itemsToShow.length < MAX_DETAIL_ITEMS) {
|
||
const allTransactions = [];
|
||
for (const subcategory of sortedChildren) {
|
||
if (subcategory.transactions && subcategory.transactions.length > 0) {
|
||
subcategory.transactions.forEach(transaction => {
|
||
if (!displayedNames.has(transaction.name)) {
|
||
allTransactions.push({
|
||
name: transaction.name,
|
||
value: transaction.value,
|
||
displayMicrocategory: transaction.displayMicrocategory,
|
||
itemStyle: { color: subcategory.itemStyle ? subcategory.itemStyle.color : '#cccccc' }
|
||
});
|
||
displayedNames.add(transaction.name);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
allTransactions.sort((a, b) => b.value - a.value);
|
||
const transactionsWithoutMicro = allTransactions.filter(t => t.displayMicrocategory === '');
|
||
if (transactionsWithoutMicro.length > 0) {
|
||
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
|
||
// Create a slightly transparent effect by mixing with white
|
||
const modifiedColor = tinycolor.mix(currentColor, '#ffffff', 30).toString();
|
||
const remaining = MAX_DETAIL_ITEMS - itemsToShow.length;
|
||
const filteredTransactions = transactionsWithoutMicro
|
||
.filter(t => !allMicrocategories.some(m => m.name === t.name))
|
||
.map(t => ({ ...t, itemStyle: { color: modifiedColor } }));
|
||
itemsToShow = itemsToShow.concat(filteredTransactions.slice(0, remaining));
|
||
}
|
||
}
|
||
} else if (params.data.transactions) {
|
||
const sortedTransactions = [...params.data.transactions].sort((a, b) => b.value - a.value);
|
||
const coloredTransactions = sortedTransactions.map(t => ({
|
||
...t,
|
||
color: params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc')
|
||
}));
|
||
itemsToShow = coloredTransactions.slice(0, MAX_DETAIL_ITEMS);
|
||
}
|
||
displayDetailsItems(itemsToShow, params.value);
|
||
}
|
||
});
|
||
|
||
// When mouse leaves a section but is still within the chart, we'll handle it with mousemove
|
||
myChart.on('mouseout', function(params) {
|
||
if (params.data) {
|
||
isOverChartSector = false;
|
||
isInsideSection = false;
|
||
// Hide the floating eye button (unless hovering over it)
|
||
hideChartEyeButton();
|
||
// Reset details with a small delay to allow mouseover of next sector to fire first
|
||
setTimeout(() => {
|
||
if (!isOverEyeButton && !isInsideSection && !isInteractingWithDetails) {
|
||
showDefaultView();
|
||
}
|
||
}, 50);
|
||
}
|
||
});
|
||
|
||
// Also reset when mouse leaves the chart container entirely
|
||
chartDom.addEventListener('mouseleave', function(e) {
|
||
// Don't reset if moving to the eye button
|
||
if (e.relatedTarget === chartEyeBtn || (e.relatedTarget && chartEyeBtn.contains(e.relatedTarget))) {
|
||
return;
|
||
}
|
||
if (isInteractingWithDetails) return;
|
||
isInsideSection = false;
|
||
showDefaultView();
|
||
});
|
||
|
||
// Add back the downplay event handler - this is triggered when sections lose emphasis
|
||
myChart.on('downplay', function(params) {
|
||
// Reset to default view when a section is no longer emphasized (unless hovering eye button or still in section)
|
||
setTimeout(() => {
|
||
if (!isOverEyeButton && !isInsideSection && !isInteractingWithDetails) {
|
||
showDefaultView();
|
||
}
|
||
}, 50);
|
||
});
|
||
}
|
||
|
||
// CSV column labels for human-readable display
|
||
const csvColumnLabels = {
|
||
transaction_date: 'Date',
|
||
processing_date: 'Processing Date',
|
||
transaction_description: 'Description',
|
||
comment: 'Comment',
|
||
mcc: 'MCC',
|
||
card_info: 'Card',
|
||
account: 'Account',
|
||
merchant_name: 'Merchant',
|
||
location: 'Location',
|
||
info_source: 'Source',
|
||
amount_original: 'Original Amount',
|
||
currency_original: 'Original Currency',
|
||
amount_rub: 'Amount (₽)',
|
||
category: 'Category',
|
||
subcategory: 'Subcategory',
|
||
microcategory: 'Microcategory',
|
||
simple_name: 'Name'
|
||
};
|
||
|
||
// Preferred column order (most important first)
|
||
const csvColumnOrder = ['transaction_date', 'simple_name', 'amount_rub', 'category', 'subcategory', 'microcategory', 'transaction_description', 'merchant_name', 'account', 'card_info', 'location', 'amount_original', 'currency_original', 'mcc', 'info_source', 'processing_date', 'comment'];
|
||
|
||
// Modal state
|
||
let currentTransactions = [];
|
||
let currentColumns = [];
|
||
let currentSortColumn = null;
|
||
let currentSortDirection = 'desc';
|
||
|
||
// Open transaction modal
|
||
function openTransactionModal(item) {
|
||
const modal = document.getElementById('transaction-modal');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
|
||
// Set title
|
||
modalTitle.textContent = item.name;
|
||
|
||
// Gather transactions
|
||
let transactions = [];
|
||
if (item.transactions && item.transactions.length > 0) {
|
||
transactions = item.transactions.filter(t => t.originalRow);
|
||
} else if (item.originalRow) {
|
||
transactions = [item];
|
||
}
|
||
|
||
if (transactions.length === 0) {
|
||
const modalTbody = document.getElementById('modal-tbody');
|
||
const modalThead = document.getElementById('modal-thead');
|
||
modalThead.innerHTML = '';
|
||
modalTbody.innerHTML = '<tr><td colspan="100%">No transaction data available</td></tr>';
|
||
modal.style.display = 'flex';
|
||
setupModalListeners();
|
||
return;
|
||
}
|
||
|
||
// Get columns from first transaction
|
||
const sampleRow = transactions[0].originalRow;
|
||
currentColumns = csvColumnOrder.filter(col => col in sampleRow);
|
||
currentTransactions = transactions;
|
||
|
||
// Initial sort by amount descending
|
||
currentSortColumn = 'amount_rub';
|
||
currentSortDirection = 'desc';
|
||
|
||
renderTransactionTable();
|
||
modal.style.display = 'flex';
|
||
|
||
// Reset scroll position to top-left
|
||
const tableContainer = document.querySelector('.modal-table-container');
|
||
tableContainer.scrollTop = 0;
|
||
tableContainer.scrollLeft = 0;
|
||
|
||
setupModalListeners();
|
||
}
|
||
|
||
// Render the transaction table with current sort
|
||
function renderTransactionTable() {
|
||
const modalThead = document.getElementById('modal-thead');
|
||
const modalTbody = document.getElementById('modal-tbody');
|
||
|
||
// Sort transactions
|
||
const sortedTransactions = [...currentTransactions].sort((a, b) => {
|
||
const aVal = a.originalRow?.[currentSortColumn] ?? '';
|
||
const bVal = b.originalRow?.[currentSortColumn] ?? '';
|
||
|
||
// Numeric sort for amount columns
|
||
if (currentSortColumn === 'amount_rub' || currentSortColumn === 'amount_original') {
|
||
const aNum = parseFloat(aVal) || 0;
|
||
const bNum = parseFloat(bVal) || 0;
|
||
return currentSortDirection === 'asc' ? aNum - bNum : bNum - aNum;
|
||
}
|
||
|
||
// String sort for other columns
|
||
const aStr = String(aVal);
|
||
const bStr = String(bVal);
|
||
const cmp = aStr.localeCompare(bStr);
|
||
return currentSortDirection === 'asc' ? cmp : -cmp;
|
||
});
|
||
|
||
// Clear content
|
||
modalThead.innerHTML = '';
|
||
modalTbody.innerHTML = '';
|
||
|
||
// Build header with sort indicators
|
||
const headerRow = document.createElement('tr');
|
||
currentColumns.forEach(col => {
|
||
const th = document.createElement('th');
|
||
th.textContent = csvColumnLabels[col] || col;
|
||
th.dataset.column = col;
|
||
|
||
// Add sort class
|
||
if (col === currentSortColumn) {
|
||
th.classList.add(currentSortDirection === 'asc' ? 'sort-asc' : 'sort-desc');
|
||
}
|
||
|
||
// Click to sort
|
||
th.addEventListener('click', () => {
|
||
if (currentSortColumn === col) {
|
||
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
currentSortColumn = col;
|
||
currentSortDirection = 'asc';
|
||
}
|
||
renderTransactionTable();
|
||
});
|
||
|
||
headerRow.appendChild(th);
|
||
});
|
||
modalThead.appendChild(headerRow);
|
||
|
||
// Build rows
|
||
sortedTransactions.forEach(transaction => {
|
||
const row = document.createElement('tr');
|
||
|
||
currentColumns.forEach(col => {
|
||
const td = document.createElement('td');
|
||
let value = transaction.originalRow[col];
|
||
|
||
if ((col === 'amount_rub' || col === 'amount_original') && typeof value === 'number') {
|
||
value = formatRUB(value);
|
||
}
|
||
|
||
td.textContent = value !== undefined && value !== null ? value : '';
|
||
td.title = td.textContent;
|
||
row.appendChild(td);
|
||
});
|
||
|
||
// Row click opens detail modal
|
||
row.addEventListener('click', () => openRowDetailModal(transaction));
|
||
modalTbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// Open the row detail modal
|
||
function openRowDetailModal(transaction) {
|
||
const modal = document.getElementById('row-detail-modal');
|
||
const title = document.getElementById('row-detail-title');
|
||
const body = document.getElementById('row-detail-body');
|
||
|
||
const name = transaction.originalRow?.simple_name || transaction.name || 'Transaction';
|
||
title.textContent = name;
|
||
|
||
body.innerHTML = '';
|
||
|
||
currentColumns.forEach(col => {
|
||
// Skip simple_name since it's already shown in the title
|
||
if (col === 'simple_name') return;
|
||
|
||
const value = transaction.originalRow[col];
|
||
|
||
const itemDiv = document.createElement('div');
|
||
itemDiv.className = 'row-detail-item';
|
||
|
||
const labelSpan = document.createElement('span');
|
||
labelSpan.className = 'row-detail-label';
|
||
labelSpan.textContent = csvColumnLabels[col] || col;
|
||
|
||
const valueSpan = document.createElement('span');
|
||
valueSpan.className = 'row-detail-value';
|
||
valueSpan.textContent = value;
|
||
|
||
itemDiv.appendChild(labelSpan);
|
||
itemDiv.appendChild(valueSpan);
|
||
body.appendChild(itemDiv);
|
||
});
|
||
|
||
modal.style.display = 'flex';
|
||
setupRowDetailModalListeners();
|
||
}
|
||
|
||
// Close row detail modal
|
||
function closeRowDetailModal() {
|
||
const modal = document.getElementById('row-detail-modal');
|
||
modal.style.display = 'none';
|
||
}
|
||
|
||
// Setup row detail modal listeners
|
||
function setupRowDetailModalListeners() {
|
||
const modal = document.getElementById('row-detail-modal');
|
||
const closeBtn = document.getElementById('row-detail-close');
|
||
|
||
closeBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
closeRowDetailModal();
|
||
};
|
||
|
||
modal.onclick = function(e) {
|
||
if (e.target === modal) {
|
||
closeRowDetailModal();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Close transaction modal
|
||
function closeTransactionModal() {
|
||
const modal = document.getElementById('transaction-modal');
|
||
modal.style.display = 'none';
|
||
// Remove any chart highlight and reset details panel
|
||
myChart.dispatchAction({ type: 'downplay' });
|
||
if (showDefaultView) showDefaultView();
|
||
}
|
||
|
||
// Global escape key handler for all modals
|
||
function handleGlobalEscape(e) {
|
||
if (e.key === 'Escape') {
|
||
const rowDetailModal = document.getElementById('row-detail-modal');
|
||
const transactionModal = document.getElementById('transaction-modal');
|
||
|
||
// Close row detail first if open, then transaction modal
|
||
if (rowDetailModal.style.display !== 'none') {
|
||
closeRowDetailModal();
|
||
} else if (transactionModal.style.display !== 'none') {
|
||
closeTransactionModal();
|
||
} else if (currentView === 'timeline' && typeof window._timelineResetLegend === 'function') {
|
||
window._timelineResetLegend();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Setup global escape listener once
|
||
document.addEventListener('keydown', handleGlobalEscape);
|
||
|
||
// Setup modal close listeners
|
||
function setupModalListeners() {
|
||
const modal = document.getElementById('transaction-modal');
|
||
const closeBtn = document.getElementById('modal-close');
|
||
|
||
closeBtn.onclick = closeTransactionModal;
|
||
|
||
modal.onclick = function(e) {
|
||
if (e.target === modal) {
|
||
closeTransactionModal();
|
||
}
|
||
};
|
||
}
|