Add browser history integration for drill-down navigation

- Push drill-down states to browser history via history.pushState()
- Listen for popstate event to handle browser back/forward
- Mouse back/forward buttons and browser buttons now navigate drill-down
- Click center still works (calls history.back())
- Reset details panel when mouse leaves chart sector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-01-30 19:48:58 +03:00
parent 08db280f84
commit 58082e02f4

119
app.js
View File

@ -4,6 +4,82 @@ const myChart = echarts.init(chartDom);
let option;
let originalSunburstData = null; // Stores the original data for the current month (for reset on center click)
// Drill-down history for back/forward navigation
let drillDownHistory = [];
let historyIndex = -1;
// Save current chart state to history
function saveToHistory(data, total, contextName, isInitial = false) {
// 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 });
historyIndex = drillDownHistory.length - 1;
// Push to browser history (use replaceState for initial state)
const state = { drillDown: historyIndex };
if (isInitial) {
history.replaceState(state, '');
} else {
history.pushState(state, '');
}
}
// Reset history (called when changing months)
function resetHistory() {
drillDownHistory = [];
historyIndex = -1;
}
// Navigate to a specific history state
function navigateToHistoryState(state) {
const russianMonth = getRussianMonthName(document.getElementById('month-select').value);
option.series.data = state.data;
if (state.contextName) {
option.graphic.elements[0].style.text = `${russianMonth}\n${state.contextName}\n${state.total.toFixed(0).toLocaleString()}`;
} else {
option.graphic.elements[0].style.text = russianMonth + '\n' + state.total.toFixed(0).toLocaleString() + ' ₽';
}
myChart.setOption(option, { replaceMerge: ['series'] });
setupHoverEvents({ total: state.total, data: state.data }, state.contextName);
}
// 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]);
}
}
// Listen for browser back/forward via popstate
window.addEventListener('popstate', function(e) {
if (e.state && e.state.drillDown !== undefined) {
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);
@ -322,6 +398,10 @@ function renderChart(data) {
// 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
const selectedMonth = document.getElementById('month-select').value;
@ -775,15 +855,18 @@ function renderChart(data) {
});
}
// Save new state to history
saveToHistory(newData, params.value, params.name);
// Update the chart with the new data structure
option.series.data = newData;
// Update the center text to show the drilled-down category
const russianMonth = getRussianMonthName(document.getElementById('month-select').value);
option.graphic.elements[0].style.text = `${russianMonth}\n${params.name}\n${params.value.toFixed(0).toLocaleString()}`;
myChart.setOption(option, { replaceMerge: ['series'] });
// Update hover events with the new data structure, passing the drilled-down name
setupHoverEvents({ total: params.value, data: newData }, params.name);
}
@ -791,29 +874,23 @@ function renderChart(data) {
myChart.setOption(option);
// Add click handler for the center to reset view
// 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) {
// Reset to original view - use module-level originalSunburstData
const currentMonth = document.getElementById('month-select').value;
const currentRussianMonth = getRussianMonthName(currentMonth);
option.series.data = originalSunburstData.data;
option.graphic.elements[0].style.text = currentRussianMonth + '\n' + originalSunburstData.total.toFixed(0).toLocaleString() + ' ₽';
myChart.setOption(option, { replaceMerge: ['series'] });
setupHoverEvents(originalSunburstData);
if (distance < innerRadius && historyIndex > 0) {
history.back(); // Use browser history - triggers popstate
}
});
@ -1054,6 +1131,10 @@ 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
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;
@ -1723,10 +1804,10 @@ function setupHoverEvents(sunburstData, contextName = null) {
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 eye button mouseenter to fire first
// But only if we're not still inside another section
// Reset details with a small delay to allow mouseover of next sector to fire first
setTimeout(() => {
if (!isOverEyeButton && !isInsideSection) {
showDefaultView();
@ -1735,6 +1816,12 @@ function setupHoverEvents(sunburstData, contextName = null) {
}
});
// Also reset when mouse leaves the chart container entirely
chartDom.addEventListener('mouseleave', function() {
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)