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:
parent
0bc2725d4d
commit
b0983a751e
427
app.js
427
app.js
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user