From 06fb6bf768d4682537b650a88b4754b9e9171e37 Mon Sep 17 00:00:00 2001 From: Anton Volnuhin Date: Fri, 6 Feb 2026 19:17:05 +0300 Subject: [PATCH] feat: add multi-level drill-down, browser history, and legend filtering to timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.js | 360 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 280 insertions(+), 80 deletions(-) diff --git a/app.js b/app.js index 36f4fdc..def9bb5 100644 --- a/app.js +++ b/app.js @@ -138,8 +138,36 @@ window.addEventListener('popstate', function(e) { 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) { + // 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; @@ -457,8 +485,10 @@ function transformToSunburst(data) { } // Build ECharts stacked bar option for timeline view -// drillCategory: if set, show subcategories of this category instead of top-level categories -function buildTimelineOption(drillCategory) { +// 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)); @@ -473,28 +503,42 @@ function buildTimelineOption(drillCategory) { color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4] }; - if (!drillCategory) { - // Top-level: collect per-category totals for each month - const categoryTotals = {}; + // Hierarchy fields in order: category → subcategory → microcategory + const hierarchyFields = ['category', 'subcategory', 'microcategory']; - months.forEach((month, mi) => { - const data = monthDataCache[month]; - if (!data) return; - data.forEach(item => { - const cat = item.category || ''; - if (!cat) return; - const amount = Math.abs(parseFloat(item.amount_rub)); - if (isNaN(amount)) return; - if (!categoryTotals[cat]) { - categoryTotals[cat] = new Array(months.length).fill(0); - } - categoryTotals[cat][mi] += amount; - }); + // 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 (!categoryTotals[catName]) return; + if (!groupTotals[catName]) return; usedCategories.add(catName); legendData.push(catName); seriesList.push({ @@ -503,7 +547,7 @@ function buildTimelineOption(drillCategory) { stack: 'total', barMaxWidth: 50, barCategoryGap: '35%', - data: categoryTotals[catName], + data: groupTotals[catName], itemStyle: { color: categoryColors[ci] }, label: { show: false }, 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; legendData.push(catName); seriesList.push({ @@ -520,38 +564,21 @@ function buildTimelineOption(drillCategory) { stack: 'total', barMaxWidth: 50, barCategoryGap: '35%', - data: categoryTotals[catName], + data: groupTotals[catName], label: { show: false }, emphasis: { focus: 'series', label: emphLabel }, blur: { itemStyle: { opacity: 0.15 } } }); }); - } else { - // Drill-down: collect per-subcategory totals for the given category - const subTotals = {}; - - 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] || []; + } 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 (!subTotals[subName]) return; + if (!groupTotals[subName]) return; usedSubs.add(subName); legendData.push(subName); seriesList.push({ @@ -560,15 +587,15 @@ function buildTimelineOption(drillCategory) { stack: 'total', barMaxWidth: 50, barCategoryGap: '35%', - data: subTotals[subName], - itemStyle: { color: getSubcategoryColor(drillCategory, subName, si) }, + data: groupTotals[subName], + itemStyle: { color: getSubcategoryColor(parentCategory, subName, si) }, label: { show: false }, emphasis: { focus: 'series', label: emphLabel }, blur: { itemStyle: { opacity: 0.15 } } }); }); - Object.keys(subTotals).forEach(subName => { + Object.keys(groupTotals).forEach(subName => { if (usedSubs.has(subName)) return; legendData.push(subName); seriesList.push({ @@ -577,16 +604,46 @@ function buildTimelineOption(drillCategory) { stack: 'total', barMaxWidth: 50, barCategoryGap: '35%', - data: subTotals[subName], - itemStyle: { color: getSubcategoryColor(drillCategory, subName, 0, unknownIdx++) }, + 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++; + }); } - // 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); seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); }); @@ -612,8 +669,9 @@ function buildTimelineOption(drillCategory) { - const chartTitle = drillCategory ? { - text: '← ' + drillCategory, + const isDrilled = drillPath.length > 0; + const chartTitle = isDrilled ? { + text: '← ' + drillPath.join(' › '), left: 'center', top: 6, textStyle: { @@ -631,7 +689,7 @@ function buildTimelineOption(drillCategory) { tooltip: { show: false }, legend: { data: legendData, - top: drillCategory ? 42 : 10, + top: isDrilled ? 42 : 10, right: 10, orient: 'vertical', itemWidth: 12, @@ -646,7 +704,7 @@ function buildTimelineOption(drillCategory) { grid: { left: 30, right: 180, - top: drillCategory ? 55 : 40, + top: isDrilled ? 55 : 40, bottom: 30, containLabel: true }, @@ -665,25 +723,62 @@ function buildTimelineOption(drillCategory) { 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 -function renderTimelineChart(drillCategory) { - timelineDrillCategory = drillCategory; +// 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) { + drillPath = drillPath || []; + historyAction = historyAction || 'push'; + timelineDrillPath = drillPath; 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(drillCategory); + 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; @@ -723,34 +818,125 @@ function renderTimelineChart(drillCategory) { } }); - // Clean up previous zrender handlers - if (window._timelineZrClickHandler) { - myChart.getZr().off('click', window._timelineZrClickHandler); - window._timelineZrClickHandler = 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; + 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) { if (params.componentType === 'title') { - renderTimelineChart(null); + // Go up one level + renderTimelineChart(drillPath.slice(0, -1)); return; } if (params.componentType === 'series' && params.seriesName !== '__total__') { - if (!drillCategory) { - renderTimelineChart(params.seriesName); + 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]); } } }); - // Background click (zrender level): go back when drilled - if (drillCategory) { - window._timelineZrClickHandler = function(e) { - if (!e.target) { - renderTimelineChart(null); + // 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) + 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; } } - }; - myChart.getZr().on('click', window._timelineZrClickHandler); - } + // 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 @@ -772,9 +958,12 @@ function switchView(viewName) { myChart.off('mouseover'); myChart.off('mouseout'); myChart.off('globalout'); + myChart.off('legendselectchanged'); // Resize after layout change, then render stacked bar myChart.resize(); - renderTimelineChart(null); + renderTimelineChart(timelineDrillPath, 'replace'); + // Ensure resize after CSS layout fully settles (needed for deferred restore) + requestAnimationFrame(() => myChart.resize()); } else { container.classList.remove('timeline-mode'); // Remove all chart event listeners @@ -782,6 +971,7 @@ function switchView(viewName) { 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; @@ -1419,7 +1609,8 @@ function saveMonthSelectionState() { currentMonth, selectedMonths, drillPath: [...currentDrillPath], - view: currentView + view: currentView, + timelineDrillPath: [...timelineDrillPath] })); } catch (error) { // Ignore storage failures (private mode, disabled storage, etc.) @@ -1459,6 +1650,11 @@ function restoreMonthSelectionState() { 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')); @@ -1923,7 +2119,11 @@ async function loadAvailableMonths() { // Set up view switcher document.querySelectorAll('.view-switcher-btn').forEach(btn => { 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); }); }); }