From b91aa65f611d1c437c2b39395c543a37ac061d1c Mon Sep 17 00:00:00 2001 From: Anton Volnuhin Date: Fri, 6 Feb 2026 17:58:08 +0300 Subject: [PATCH] feat: enhance timeline view with drill-down, category highlighting, and native labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add drill-down into subcategories when clicking a category bar - Use native ECharts emphasis/blur for category highlighting (focus: series) - Show per-category labels on all bars via dispatchAction highlight - Display total sum labels (к) above bars, ₽ on y-axis only - Add color legend at bottom of chart - Increase font sizes and angle x-axis labels for readability - Remove details panel from timeline mode - Clean up label management: no manual setOption race conditions Co-Authored-By: Claude Opus 4.6 --- app.js | 351 ++++++++++++++++++++++++++++++++++------------------- styles.css | 30 +---- 2 files changed, 230 insertions(+), 151 deletions(-) diff --git a/app.js b/app.js index 0fca778..1055321 100644 --- a/app.js +++ b/app.js @@ -457,140 +457,272 @@ function transformToSunburst(data) { } // Build ECharts stacked bar option for timeline view -function buildTimelineOption() { +// drillCategory: if set, show subcategories of this category instead of top-level categories +function buildTimelineOption(drillCategory) { const months = availableMonths; const xLabels = months.map(m => formatMonthLabel(m)); - // Collect per-category totals for each month - const categoryTotals = {}; // { categoryName: [amountForMonth0, amountForMonth1, ...] } - - 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; - }); - }); - - // Build series in categoryOrder, then append any unknown categories const seriesList = []; - const usedCategories = new Set(); + const legendData = []; - categoryOrder.forEach((catName, ci) => { - if (!categoryTotals[catName]) return; - usedCategories.add(catName); - seriesList.push({ - name: catName, - type: 'bar', - stack: 'total', - barMaxWidth: 50, - barCategoryGap: '35%', - data: categoryTotals[catName], - itemStyle: { color: categoryColors[ci] } + const emphLabel = { + show: true, position: 'top', + formatter: (p) => p.value >= 1000 ? Math.round(p.value / 1000) + 'к' : '', + fontSize: 14, fontWeight: 'bold', + fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif', + 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 = {}; + + 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; + }); }); + + const usedCategories = new Set(); + categoryOrder.forEach((catName, ci) => { + if (!categoryTotals[catName]) return; + usedCategories.add(catName); + legendData.push(catName); + seriesList.push({ + name: catName, + type: 'bar', + stack: 'total', + barMaxWidth: 50, + barCategoryGap: '35%', + data: categoryTotals[catName], + itemStyle: { color: categoryColors[ci] }, + label: { show: false }, + emphasis: { focus: 'series', label: emphLabel }, + blur: { itemStyle: { opacity: 0.15 } } + }); + }); + + Object.keys(categoryTotals).forEach(catName => { + if (usedCategories.has(catName)) return; + legendData.push(catName); + seriesList.push({ + name: catName, + type: 'bar', + stack: 'total', + barMaxWidth: 50, + barCategoryGap: '35%', + data: categoryTotals[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] || []; + const usedSubs = new Set(); + let unknownIdx = 0; + + order.forEach((subName, si) => { + if (!subTotals[subName]) return; + usedSubs.add(subName); + legendData.push(subName); + seriesList.push({ + name: subName, + type: 'bar', + stack: 'total', + barMaxWidth: 50, + barCategoryGap: '35%', + data: subTotals[subName], + itemStyle: { color: getSubcategoryColor(drillCategory, subName, si) }, + label: { show: false }, + emphasis: { focus: 'series', label: emphLabel }, + blur: { itemStyle: { opacity: 0.15 } } + }); + }); + + Object.keys(subTotals).forEach(subName => { + if (usedSubs.has(subName)) return; + legendData.push(subName); + seriesList.push({ + name: subName, + type: 'bar', + stack: 'total', + barMaxWidth: 50, + barCategoryGap: '35%', + data: subTotals[subName], + itemStyle: { color: getSubcategoryColor(drillCategory, subName, 0, unknownIdx++) }, + label: { show: false }, + emphasis: { focus: 'series', label: emphLabel }, + blur: { itemStyle: { opacity: 0.15 } } + }); + }); + } + + // Invisible "total" series on top — shows sum labels by default + const totalPerMonth = new Array(months.length).fill(0); + seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); }); + + seriesList.push({ + name: '__total__', + type: 'bar', + stack: 'total', + barMaxWidth: 50, + data: totalPerMonth.map(() => 1), + itemStyle: { color: 'transparent' }, + label: { + show: true, + position: 'top', + formatter: (params) => Math.round(totalPerMonth[params.dataIndex] / 1000) + 'к', + fontSize: 14, + fontWeight: 'bold', + fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif', + color: '#666' + }, + emphasis: { disabled: true }, + blur: { label: { show: false }, itemStyle: { opacity: 0 } } }); - // Unknown categories (not in categoryOrder) - Object.keys(categoryTotals).forEach(catName => { - if (usedCategories.has(catName)) return; - seriesList.push({ - name: catName, - type: 'bar', - stack: 'total', - barMaxWidth: 50, - barCategoryGap: '35%', - data: categoryTotals[catName] - }); - }); + + + const chartTitle = drillCategory ? { + text: '← ' + drillCategory, + left: 20, + top: 10, + textStyle: { fontSize: 16, fontWeight: 500, color: '#555' }, + triggerEvent: true + } : { show: false }; return { backgroundColor: '#fff', + title: chartTitle, tooltip: { show: false }, + legend: { + data: legendData, + bottom: 0, + left: 'center', + itemWidth: 14, + itemHeight: 14, + textStyle: { fontSize: 13 }, + itemGap: 16 + }, grid: { left: 30, - right: '28%', - top: 30, - bottom: 30, + right: 30, + top: drillCategory ? 50 : 40, + bottom: 50, containLabel: true }, xAxis: { type: 'category', data: xLabels, - axisLabel: { fontSize: 11 } + axisLabel: { fontSize: 13, rotate: -45 } }, yAxis: { type: 'value', axisLabel: { formatter: function(val) { - if (val >= 1000) return Math.round(val / 1000) + 'k'; + if (val >= 1000) return Math.round(val / 1000) + 'к\u202F₽'; return val; }, - fontSize: 11 + fontSize: 13 } }, series: seriesList }; } -// Update details panel for timeline view -function updateTimelineDetails(monthIndex) { - const detailsHeader = document.getElementById('details-header'); - const topItemsEl = document.getElementById('top-items'); +let timelineDrillCategory = null; - // Reset header to clean state (remove any eye buttons from sunburst mode) - detailsHeader.innerHTML = ''; - const hoverName = detailsHeader.querySelector('.hover-name'); - const hoverAmount = detailsHeader.querySelector('.hover-amount'); +// Render (or re-render) the timeline chart, optionally drilled into a category +function renderTimelineChart(drillCategory) { + timelineDrillCategory = drillCategory; - let monthLabel, data; - if (monthIndex === null || monthIndex === undefined) { - monthLabel = 'Все месяцы'; - data = []; - for (const month of availableMonths) { - if (monthDataCache[month]) data.push(...monthDataCache[month]); + myChart.off('click'); + myChart.off('mouseover'); + myChart.off('mouseout'); + myChart.off('globalout'); + + const tlOption = buildTimelineOption(drillCategory); + myChart.clear(); + myChart.setOption(tlOption, true); + + // Highlight entire series on hover so all bars show emphasis.label + let hlSeries = null; + myChart.on('mouseover', function(params) { + if (params.componentType !== 'series' || params.seriesName === '__total__') return; + if (hlSeries === params.seriesName) return; + if (hlSeries) myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries }); + hlSeries = params.seriesName; + myChart.dispatchAction({ type: 'highlight', seriesName: params.seriesName }); + }); + myChart.on('mouseout', function(params) { + if (params.componentType !== 'series' || !hlSeries) return; + myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries }); + hlSeries = null; + }); + myChart.on('globalout', function() { + if (hlSeries) { + myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries }); + hlSeries = null; } - } else { - const month = availableMonths[monthIndex]; - monthLabel = formatMonthLabel(month); - data = monthDataCache[month] || []; + }); + + // Clean up previous zrender handlers + if (window._timelineZrClickHandler) { + myChart.getZr().off('click', window._timelineZrClickHandler); + window._timelineZrClickHandler = null; } - // Compute category totals - const catTotals = {}; - let total = 0; - data.forEach(item => { - const cat = item.category || ''; - if (!cat) return; - const amount = Math.abs(parseFloat(item.amount_rub)); - if (isNaN(amount)) return; - catTotals[cat] = (catTotals[cat] || 0) + amount; - total += amount; + // Click handler: drill down into category, or go back from subcategory view + myChart.on('click', function(params) { + if (params.componentType === 'title') { + renderTimelineChart(null); + return; + } + if (params.componentType === 'series' && params.seriesName !== '__total__') { + if (!drillCategory) { + renderTimelineChart(params.seriesName); + } + } }); - hoverName.textContent = monthLabel; - hoverAmount.textContent = formatRUB(total) + '\u202F₽'; - - // Sort by value descending - const sorted = Object.entries(catTotals).sort((a, b) => b[1] - a[1]); - - topItemsEl.innerHTML = ''; - sorted.forEach(([cat, amount]) => { - const pct = ((amount / total) * 100).toFixed(1); - const colorIdx = categoryOrder.indexOf(cat); - const color = colorIdx !== -1 ? categoryColors[colorIdx] : '#999'; - - const item = document.createElement('div'); - item.className = 'top-item'; - item.innerHTML = `${cat}${pct}%${formatRUB(amount)}\u202F₽`; - topItemsEl.appendChild(item); - }); + // Background click (zrender level): go back when drilled + if (drillCategory) { + window._timelineZrClickHandler = function(e) { + if (!e.target) { + renderTimelineChart(null); + } + }; + myChart.getZr().on('click', window._timelineZrClickHandler); + } } // Switch between month (sunburst) and timeline (stacked bar) views @@ -614,34 +746,7 @@ function switchView(viewName) { myChart.off('globalout'); // Resize after layout change, then render stacked bar myChart.resize(); - myChart.setOption(buildTimelineOption(), true); - - // Hover events update the details panel - myChart.on('mouseover', function(params) { - if (params.componentType === 'series') { - updateTimelineDetails(params.dataIndex); - } - }); - myChart.on('globalout', function() { - updateTimelineDetails(null); - }); - // Show aggregate by default - updateTimelineDetails(null); - - // Click handler: clicking a bar switches to month view for that month - myChart.on('click', function(params) { - if (params.componentType === 'series') { - const monthIndex = params.dataIndex; - if (monthIndex >= 0 && monthIndex < availableMonths.length) { - selectedMonthIndices.clear(); - selectedMonthIndices.add(monthIndex); - currentMonthIndex = monthIndex; - switchView('month'); - selectMonth(monthIndex); - saveMonthSelectionState(); - } - } - }); + renderTimelineChart(null); } else { container.classList.remove('timeline-mode'); // Remove all chart event listeners diff --git a/styles.css b/styles.css index ecd16b7..a448142 100644 --- a/styles.css +++ b/styles.css @@ -157,7 +157,8 @@ body { /* Timeline mode modifiers */ .container.timeline-mode #center-label, -.container.timeline-mode #chart-eye-btn { +.container.timeline-mode #chart-eye-btn, +.container.timeline-mode #details-box { display: none !important; } @@ -170,20 +171,6 @@ body { width: 100%; } -.container.timeline-mode #details-box { - position: absolute; - top: 20px; - right: 30px; - width: 280px; - min-width: 0; - max-width: 280px; - background: transparent; - box-shadow: none; - border: none; - opacity: 1; - z-index: 10; -} - .content-wrapper { display: flex; position: relative; @@ -480,19 +467,6 @@ body { width: 100%; } - .container.timeline-mode #details-box { - position: relative; - top: auto; - right: auto; - width: 100%; - min-width: 100%; - max-width: none; - background: white; - box-shadow: 0 2px 15px rgba(0, 0, 0, 0.15); - border: 1px solid #eee; - margin-top: 15px; - } - .top-item-amount { min-width: 80px; }