feat: sync drill-down state across mini-charts and months

- Add currentDrillPath to track category hierarchy during drill-down
- Mini-charts now update to show the same category breakdown when
  drilling into a category on the main chart
- Switching months while drilled down preserves the category path,
  navigating to the same category in the new month
- Empty state shown for months that don't have the drilled category
- Browser back/forward navigation syncs mini-charts correctly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-01-31 17:50:48 +03:00
parent 0bc2725d4d
commit b0983a751e

425
app.js
View File

@ -8,16 +8,20 @@ let showDefaultView = null; // Reference to reset details panel to default view
// 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) {
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 });
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) {
@ -31,6 +35,7 @@ function saveToHistory(data, total, contextName, isInitial = false) {
function resetHistory() {
drillDownHistory = [];
historyIndex = -1;
currentDrillPath = [];
}
// Navigate to a specific history state
@ -46,6 +51,10 @@ function navigateToHistoryState(state) {
myChart.setOption(option, { replaceMerge: ['series'] });
setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
// Restore drill path and update mini-charts
currentDrillPath = state.path ? [...state.path] : [];
updateAllMonthPreviews();
}
// Go back in drill-down history
@ -421,7 +430,7 @@ function renderChart(data) {
// Reset and initialize history with the root state
resetHistory();
saveToHistory(sunburstData.data, sunburstData.total, null, true);
saveToHistory(sunburstData.data, sunburstData.total, null, true, []);
// Get the currently selected month
const selectedMonth = document.getElementById('month-select').value;
@ -876,8 +885,9 @@ function renderChart(data) {
});
}
// Save new state to history
saveToHistory(newData, params.value, params.name);
// 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;
@ -890,6 +900,9 @@ function renderChart(data) {
// 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();
}
});
@ -1042,6 +1055,141 @@ function generateMonthPreviewGradient(data) {
return `conic-gradient(${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(#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(#e0e0e0 0deg 360deg)';
}
// Sort by value descending
const sortedKeys = Object.keys(totals).sort((a, b) => totals[b] - totals[a]);
// Generate gradient with color variations
const baseColor = getPathBaseColor(path);
const colors = generateColorGradient(baseColor, sortedKeys.length);
const gradientStops = [];
let currentAngle = 0;
sortedKeys.forEach((key, index) => {
const percentage = totals[key] / total;
const angle = percentage * 360;
const color = colors[index] || categoryColors[index % categoryColors.length];
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
currentAngle += angle;
});
return `conic-gradient(${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('-');
@ -1130,6 +1278,203 @@ async function loadAvailableMonths() {
});
}
// Transform children data for drill-down (extracted from click handler)
function transformDrillDownData(parentNode) {
const colorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
const newData = [];
const sortedChildren = [...parentNode.children].sort((a, b) => b.value - a.value);
sortedChildren.forEach((child, i) => {
const color = colorPalette[i % colorPalette.length];
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);
} 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
};
}
// Select and load a specific month
async function selectMonth(index) {
currentMonthIndex = index;
@ -1143,7 +1488,7 @@ async function selectMonth(index) {
updateMonthNavigator();
// Load and render data
const data = await parseCSV(`altcats-${month}.csv`);
const data = monthDataCache[month] || await parseCSV(`altcats-${month}.csv`);
// Check if chart already has data (for animation)
if (option && option.series && option.series.data) {
@ -1152,32 +1497,49 @@ async function selectMonth(index) {
// Update the module-level original data for center-click reset
originalSunburstData = JSON.parse(JSON.stringify(sunburstData));
// Reset and initialize history for the new month
// Save the current drill path before modifying state
const savedPath = [...currentDrillPath];
// Reset history for the new month
resetHistory();
saveToHistory(sunburstData.data, sunburstData.total, null, true);
// Update only the series data and preserve layout
const oldData = option.series.data;
const newData = sunburstData.data;
// Try to navigate to the same path in the new month
const navigatedState = navigateToPath(sunburstData, savedPath);
// Map old values to new data to preserve positions
newData.forEach((newItem, idx) => {
if (oldData[idx]) {
newItem.layoutId = oldData[idx].name;
let targetData, targetTotal, targetName, targetPath;
if (navigatedState) {
// Successfully navigated to (part of) the path
targetData = navigatedState.data;
targetTotal = navigatedState.total;
targetName = navigatedState.contextName;
targetPath = navigatedState.path;
} else {
// Stay at root level
targetData = sunburstData.data;
targetTotal = sunburstData.total;
targetName = null;
targetPath = [];
}
});
// Save initial state with the path
saveToHistory(targetData, targetTotal, targetName, true, targetPath);
// Update the data
option.series.data = newData;
option.series.data = targetData;
// Update the total amount in the center text
const russianMonth = getRussianMonthName(month);
option.graphic.elements[0].style.text = russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽';
if (targetName) {
option.graphic.elements[0].style.text = `${russianMonth}\n${targetName}\n${targetTotal.toFixed(0).toLocaleString()}`;
} else {
option.graphic.elements[0].style.text = russianMonth + '\n' + targetTotal.toFixed(0).toLocaleString() + ' ₽';
}
myChart.setOption({
series: [{
type: 'sunburst',
data: newData,
data: targetData,
layoutAnimation: true,
animationDuration: 500,
animationEasing: 'cubicInOut'
@ -1189,7 +1551,10 @@ async function selectMonth(index) {
});
// Update hover events
setupHoverEvents(sunburstData);
setupHoverEvents({ total: targetTotal, data: targetData }, targetName);
// Update mini-chart previews
updateAllMonthPreviews();
} else {
// Initial render
renderChart(data);
@ -1563,6 +1928,15 @@ function setupHoverEvents(sunburstData, contextName = null) {
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';
@ -1577,13 +1951,6 @@ function setupHoverEvents(sunburstData, contextName = null) {
const amountSpan = document.createElement('span');
amountSpan.className = 'top-item-amount';
amountSpan.textContent = item.value.toLocaleString() + ' ₽';
// Add percentage if we have a parent value
if (parentValue) {
const percentage = ((item.value / parentValue) * 100).toFixed(1);
amountSpan.textContent += ` (${percentage}%)`;
}
itemDiv.appendChild(amountSpan);
topItemsElement.appendChild(itemDiv);
});
@ -1663,8 +2030,8 @@ function setupHoverEvents(sunburstData, contextName = null) {
});
}
// Show top categories as default items
displayDetailsItems(sunburstData.data, sunburstData.total);
// Show top categories as default items (sorted by value descending)
displayDetailsItems([...sunburstData.data].sort((a, b) => b.value - a.value), sunburstData.total);
}
// Show default view initially