feat: add multi-level drill-down, browser history, and legend filtering to timeline

- Support 3-level drill: category → subcategory → microcategory
- Integrate browser back/forward with drill path and legend selection state
- Cmd-click isolates single series, Opt-click toggles series off
- Dynamic total labels update to reflect only visible series
- Add "Очистить" reset button when series are filtered
- Click month labels to switch to that month's donut view
- Persist timeline drill path in localStorage across reloads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-02-06 19:17:05 +03:00
parent 9b848610ae
commit 06fb6bf768

360
app.js
View File

@ -138,8 +138,36 @@ window.addEventListener('popstate', function(e) {
return; return;
} }
// No modal open - handle drill-down navigation // 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) { 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; const stateIndex = e.state.drillDown;
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) { if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
historyIndex = stateIndex; historyIndex = stateIndex;
@ -457,8 +485,10 @@ function transformToSunburst(data) {
} }
// Build ECharts stacked bar option for timeline view // Build ECharts stacked bar option for timeline view
// drillCategory: if set, show subcategories of this category instead of top-level categories // drillPath: array like [] (categories), ['Cat'] (subcategories), ['Cat','Sub'] (microcategories)
function buildTimelineOption(drillCategory) { function buildTimelineOption(drillPath) {
drillPath = drillPath || [];
const depth = drillPath.length; // 0=category, 1=subcategory, 2=microcategory
const months = availableMonths; const months = availableMonths;
const xLabels = months.map(m => formatMonthLabel(m)); const xLabels = months.map(m => formatMonthLabel(m));
@ -473,28 +503,42 @@ function buildTimelineOption(drillCategory) {
color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4] color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4]
}; };
if (!drillCategory) { // Hierarchy fields in order: category → subcategory → microcategory
// Top-level: collect per-category totals for each month const hierarchyFields = ['category', 'subcategory', 'microcategory'];
const categoryTotals = {};
months.forEach((month, mi) => { // Collect totals grouped by the next level in the hierarchy
const data = monthDataCache[month]; const groupTotals = {};
if (!data) return;
data.forEach(item => { months.forEach((month, mi) => {
const cat = item.category || ''; const data = monthDataCache[month];
if (!cat) return; if (!data) return;
const amount = Math.abs(parseFloat(item.amount_rub)); data.forEach(item => {
if (isNaN(amount)) return; const amount = Math.abs(parseFloat(item.amount_rub));
if (!categoryTotals[cat]) { if (isNaN(amount)) return;
categoryTotals[cat] = new Array(months.length).fill(0);
} // Filter: item must match all path segments
categoryTotals[cat][mi] += amount; 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(); const usedCategories = new Set();
categoryOrder.forEach((catName, ci) => { categoryOrder.forEach((catName, ci) => {
if (!categoryTotals[catName]) return; if (!groupTotals[catName]) return;
usedCategories.add(catName); usedCategories.add(catName);
legendData.push(catName); legendData.push(catName);
seriesList.push({ seriesList.push({
@ -503,7 +547,7 @@ function buildTimelineOption(drillCategory) {
stack: 'total', stack: 'total',
barMaxWidth: 50, barMaxWidth: 50,
barCategoryGap: '35%', barCategoryGap: '35%',
data: categoryTotals[catName], data: groupTotals[catName],
itemStyle: { color: categoryColors[ci] }, itemStyle: { color: categoryColors[ci] },
label: { show: false }, label: { show: false },
emphasis: { focus: 'series', label: emphLabel }, emphasis: { focus: 'series', label: emphLabel },
@ -511,7 +555,7 @@ function buildTimelineOption(drillCategory) {
}); });
}); });
Object.keys(categoryTotals).forEach(catName => { Object.keys(groupTotals).forEach(catName => {
if (usedCategories.has(catName)) return; if (usedCategories.has(catName)) return;
legendData.push(catName); legendData.push(catName);
seriesList.push({ seriesList.push({
@ -520,38 +564,21 @@ function buildTimelineOption(drillCategory) {
stack: 'total', stack: 'total',
barMaxWidth: 50, barMaxWidth: 50,
barCategoryGap: '35%', barCategoryGap: '35%',
data: categoryTotals[catName], data: groupTotals[catName],
label: { show: false }, label: { show: false },
emphasis: { focus: 'series', label: emphLabel }, emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } } blur: { itemStyle: { opacity: 0.15 } }
}); });
}); });
} else { } else if (depth === 1) {
// Drill-down: collect per-subcategory totals for the given category // Level 1: use subcategoryOrder and getSubcategoryColor
const subTotals = {}; const parentCategory = drillPath[0];
const order = subcategoryOrder[parentCategory] || [];
months.forEach((month, mi) => {
const data = monthDataCache[month];
if (!data) return;
data.forEach(item => {
if ((item.category || '') !== drillCategory) return;
const sub = item.subcategory || 'Другое';
const amount = Math.abs(parseFloat(item.amount_rub));
if (isNaN(amount)) return;
if (!subTotals[sub]) {
subTotals[sub] = new Array(months.length).fill(0);
}
subTotals[sub][mi] += amount;
});
});
// Use subcategoryOrder for consistent ordering and colors
const order = subcategoryOrder[drillCategory] || [];
const usedSubs = new Set(); const usedSubs = new Set();
let unknownIdx = 0; let unknownIdx = 0;
order.forEach((subName, si) => { order.forEach((subName, si) => {
if (!subTotals[subName]) return; if (!groupTotals[subName]) return;
usedSubs.add(subName); usedSubs.add(subName);
legendData.push(subName); legendData.push(subName);
seriesList.push({ seriesList.push({
@ -560,15 +587,15 @@ function buildTimelineOption(drillCategory) {
stack: 'total', stack: 'total',
barMaxWidth: 50, barMaxWidth: 50,
barCategoryGap: '35%', barCategoryGap: '35%',
data: subTotals[subName], data: groupTotals[subName],
itemStyle: { color: getSubcategoryColor(drillCategory, subName, si) }, itemStyle: { color: getSubcategoryColor(parentCategory, subName, si) },
label: { show: false }, label: { show: false },
emphasis: { focus: 'series', label: emphLabel }, emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } } blur: { itemStyle: { opacity: 0.15 } }
}); });
}); });
Object.keys(subTotals).forEach(subName => { Object.keys(groupTotals).forEach(subName => {
if (usedSubs.has(subName)) return; if (usedSubs.has(subName)) return;
legendData.push(subName); legendData.push(subName);
seriesList.push({ seriesList.push({
@ -577,16 +604,46 @@ function buildTimelineOption(drillCategory) {
stack: 'total', stack: 'total',
barMaxWidth: 50, barMaxWidth: 50,
barCategoryGap: '35%', barCategoryGap: '35%',
data: subTotals[subName], data: groupTotals[subName],
itemStyle: { color: getSubcategoryColor(drillCategory, subName, 0, unknownIdx++) }, itemStyle: { color: getSubcategoryColor(parentCategory, subName, 0, unknownIdx++) },
label: { show: false }, label: { show: false },
emphasis: { focus: 'series', label: emphLabel }, emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } } 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++;
});
} }
// Invisible "total" series on top — shows sum labels by default // 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); const totalPerMonth = new Array(months.length).fill(0);
seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); }); seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
@ -612,8 +669,9 @@ function buildTimelineOption(drillCategory) {
const chartTitle = drillCategory ? { const isDrilled = drillPath.length > 0;
text: '← ' + drillCategory, const chartTitle = isDrilled ? {
text: '← ' + drillPath.join(' '),
left: 'center', left: 'center',
top: 6, top: 6,
textStyle: { textStyle: {
@ -631,7 +689,7 @@ function buildTimelineOption(drillCategory) {
tooltip: { show: false }, tooltip: { show: false },
legend: { legend: {
data: legendData, data: legendData,
top: drillCategory ? 42 : 10, top: isDrilled ? 42 : 10,
right: 10, right: 10,
orient: 'vertical', orient: 'vertical',
itemWidth: 12, itemWidth: 12,
@ -646,7 +704,7 @@ function buildTimelineOption(drillCategory) {
grid: { grid: {
left: 30, left: 30,
right: 180, right: 180,
top: drillCategory ? 55 : 40, top: isDrilled ? 55 : 40,
bottom: 30, bottom: 30,
containLabel: true containLabel: true
}, },
@ -665,25 +723,62 @@ function buildTimelineOption(drillCategory) {
fontSize: 13 fontSize: 13
} }
}, },
series: seriesList series: seriesList,
_seriesDataMap: seriesDataMap,
_monthCount: months.length
}; };
} }
let timelineDrillCategory = null; let timelineDrillPath = [];
// Render (or re-render) the timeline chart, optionally drilled into a category // Render (or re-render) the timeline chart, optionally drilled into a category path
function renderTimelineChart(drillCategory) { // historyAction: 'push' (default, user drill-down), 'replace' (initial/view switch), 'none' (popstate restore)
timelineDrillCategory = drillCategory; function renderTimelineChart(drillPath, historyAction, legendSelected) {
drillPath = drillPath || [];
historyAction = historyAction || 'push';
timelineDrillPath = drillPath;
myChart.off('click'); myChart.off('click');
myChart.off('mouseover'); myChart.off('mouseover');
myChart.off('mouseout'); myChart.off('mouseout');
myChart.off('globalout'); 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(drillCategory); const tlOption = buildTimelineOption(drillPath);
myChart.clear(); myChart.clear();
myChart.setOption(tlOption, true); 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 // Highlight entire series on hover so all bars show emphasis.label
// Also highlight the corresponding legend item // Also highlight the corresponding legend item
const legendNames = tlOption.legend.data; const legendNames = tlOption.legend.data;
@ -723,34 +818,125 @@ function renderTimelineChart(drillCategory) {
} }
}); });
// Clean up previous zrender handlers // Track whether all series are selected
if (window._timelineZrClickHandler) { function updateTotalsAndResetBtn(selected) {
myChart.getZr().off('click', window._timelineZrClickHandler); const dataMap = tlOption._seriesDataMap;
window._timelineZrClickHandler = null; const n = tlOption._monthCount;
const visibleTotals = new Array(n).fill(0);
const allNames = tlOption.legend.data;
let allSelected = true;
Object.keys(dataMap).forEach(name => {
if (selected[name] !== false) {
dataMap[name].forEach((v, i) => { visibleTotals[i] += v; });
} else {
allSelected = false;
}
});
myChart.setOption({
series: [{ name: '__total__', label: {
formatter: (p) => Math.round(visibleTotals[p.dataIndex] / 1000) + 'к'
}}],
graphic: [{
type: 'text',
id: 'resetBtn',
right: 22,
top: (drillPath.length > 0 ? 42 : 10) + allNames.length * 20 + 16,
style: {
text: allSelected ? '' : 'Очистить',
fontSize: 13,
fill: '#999',
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif'
},
cursor: 'pointer',
onclick: 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 }, '');
}
}]
});
} }
// Click handler: drill down into category, or go back from subcategory view // 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) { myChart.on('click', function(params) {
if (params.componentType === 'title') { if (params.componentType === 'title') {
renderTimelineChart(null); // Go up one level
renderTimelineChart(drillPath.slice(0, -1));
return; return;
} }
if (params.componentType === 'series' && params.seriesName !== '__total__') { if (params.componentType === 'series' && params.seriesName !== '__total__') {
if (!drillCategory) { const nativeEvent = params.event && params.event.event;
renderTimelineChart(params.seriesName); // 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]);
} }
} }
}); });
// Background click (zrender level): go back when drilled // Zrender-level click: detect clicks in the x-axis label area → switch to that month
if (drillCategory) { window._timelineZrLabelHandler = function(e) {
window._timelineZrClickHandler = function(e) { const y = e.offsetY;
if (!e.target) { const yBottom = myChart.convertToPixel({yAxisIndex: 0}, 0);
renderTimelineChart(null); // Click must be below the bar area (in the label zone)
if (y > yBottom + 5) {
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
myChart.getZr().on('click', window._timelineZrClickHandler); 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 // Switch between month (sunburst) and timeline (stacked bar) views
@ -772,9 +958,12 @@ function switchView(viewName) {
myChart.off('mouseover'); myChart.off('mouseover');
myChart.off('mouseout'); myChart.off('mouseout');
myChart.off('globalout'); myChart.off('globalout');
myChart.off('legendselectchanged');
// Resize after layout change, then render stacked bar // Resize after layout change, then render stacked bar
myChart.resize(); myChart.resize();
renderTimelineChart(null); renderTimelineChart(timelineDrillPath, 'replace');
// Ensure resize after CSS layout fully settles (needed for deferred restore)
requestAnimationFrame(() => myChart.resize());
} else { } else {
container.classList.remove('timeline-mode'); container.classList.remove('timeline-mode');
// Remove all chart event listeners // Remove all chart event listeners
@ -782,6 +971,7 @@ function switchView(viewName) {
myChart.off('mouseover'); myChart.off('mouseover');
myChart.off('mouseout'); myChart.off('mouseout');
myChart.off('globalout'); myChart.off('globalout');
myChart.off('legendselectchanged');
// Clear chart completely so no bar chart remnants remain // Clear chart completely so no bar chart remnants remain
myChart.clear(); myChart.clear();
option = null; option = null;
@ -1419,7 +1609,8 @@ function saveMonthSelectionState() {
currentMonth, currentMonth,
selectedMonths, selectedMonths,
drillPath: [...currentDrillPath], drillPath: [...currentDrillPath],
view: currentView view: currentView,
timelineDrillPath: [...timelineDrillPath]
})); }));
} catch (error) { } catch (error) {
// Ignore storage failures (private mode, disabled storage, etc.) // Ignore storage failures (private mode, disabled storage, etc.)
@ -1459,6 +1650,11 @@ function restoreMonthSelectionState() {
currentDrillPath = []; currentDrillPath = [];
} }
// Restore timeline drill path if saved
if (Array.isArray(saved.timelineDrillPath)) {
timelineDrillPath = [...saved.timelineDrillPath];
}
// Defer timeline restore so chart is initialized first // Defer timeline restore so chart is initialized first
if (saved.view === 'timeline') { if (saved.view === 'timeline') {
requestAnimationFrame(() => switchView('timeline')); requestAnimationFrame(() => switchView('timeline'));
@ -1923,7 +2119,11 @@ async function loadAvailableMonths() {
// Set up view switcher // Set up view switcher
document.querySelectorAll('.view-switcher-btn').forEach(btn => { document.querySelectorAll('.view-switcher-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
switchView(btn.dataset.view); const target = btn.dataset.view;
if (target !== currentView) {
history.pushState(target === 'timeline' ? { timelineDrill: timelineDrillPath } : { monthView: true }, '');
}
switchView(target);
}); });
}); });
} }