feat: enhance timeline view with drill-down, category highlighting, and native labels

- 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 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-02-06 17:58:08 +03:00
parent 33a245728d
commit b91aa65f61
2 changed files with 230 additions and 151 deletions

281
app.js
View File

@ -457,12 +457,25 @@ function transformToSunburst(data) {
} }
// Build ECharts stacked bar option for timeline view // 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 months = availableMonths;
const xLabels = months.map(m => formatMonthLabel(m)); const xLabels = months.map(m => formatMonthLabel(m));
// Collect per-category totals for each month const seriesList = [];
const categoryTotals = {}; // { categoryName: [amountForMonth0, amountForMonth1, ...] } const legendData = [];
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) => { months.forEach((month, mi) => {
const data = monthDataCache[month]; const data = monthDataCache[month];
@ -479,13 +492,11 @@ function buildTimelineOption() {
}); });
}); });
// Build series in categoryOrder, then append any unknown categories
const seriesList = [];
const usedCategories = new Set(); const usedCategories = new Set();
categoryOrder.forEach((catName, ci) => { categoryOrder.forEach((catName, ci) => {
if (!categoryTotals[catName]) return; if (!categoryTotals[catName]) return;
usedCategories.add(catName); usedCategories.add(catName);
legendData.push(catName);
seriesList.push({ seriesList.push({
name: catName, name: catName,
type: 'bar', type: 'bar',
@ -493,104 +504,225 @@ function buildTimelineOption() {
barMaxWidth: 50, barMaxWidth: 50,
barCategoryGap: '35%', barCategoryGap: '35%',
data: categoryTotals[catName], data: categoryTotals[catName],
itemStyle: { color: categoryColors[ci] } itemStyle: { color: categoryColors[ci] },
label: { show: false },
emphasis: { focus: 'series', label: emphLabel },
blur: { itemStyle: { opacity: 0.15 } }
}); });
}); });
// Unknown categories (not in categoryOrder)
Object.keys(categoryTotals).forEach(catName => { Object.keys(categoryTotals).forEach(catName => {
if (usedCategories.has(catName)) return; if (usedCategories.has(catName)) return;
legendData.push(catName);
seriesList.push({ seriesList.push({
name: catName, name: catName,
type: 'bar', type: 'bar',
stack: 'total', stack: 'total',
barMaxWidth: 50, barMaxWidth: 50,
barCategoryGap: '35%', barCategoryGap: '35%',
data: categoryTotals[catName] 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 } }
});
const chartTitle = drillCategory ? {
text: '← ' + drillCategory,
left: 20,
top: 10,
textStyle: { fontSize: 16, fontWeight: 500, color: '#555' },
triggerEvent: true
} : { show: false };
return { return {
backgroundColor: '#fff', backgroundColor: '#fff',
title: chartTitle,
tooltip: { show: false }, tooltip: { show: false },
legend: {
data: legendData,
bottom: 0,
left: 'center',
itemWidth: 14,
itemHeight: 14,
textStyle: { fontSize: 13 },
itemGap: 16
},
grid: { grid: {
left: 30, left: 30,
right: '28%', right: 30,
top: 30, top: drillCategory ? 50 : 40,
bottom: 30, bottom: 50,
containLabel: true containLabel: true
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
data: xLabels, data: xLabels,
axisLabel: { fontSize: 11 } axisLabel: { fontSize: 13, rotate: -45 }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
formatter: function(val) { formatter: function(val) {
if (val >= 1000) return Math.round(val / 1000) + 'k'; if (val >= 1000) return Math.round(val / 1000) + 'к\u202F₽';
return val; return val;
}, },
fontSize: 11 fontSize: 13
} }
}, },
series: seriesList series: seriesList
}; };
} }
// Update details panel for timeline view let timelineDrillCategory = null;
function updateTimelineDetails(monthIndex) {
const detailsHeader = document.getElementById('details-header');
const topItemsEl = document.getElementById('top-items');
// Reset header to clean state (remove any eye buttons from sunburst mode) // Render (or re-render) the timeline chart, optionally drilled into a category
detailsHeader.innerHTML = '<span class="hover-name"></span><span class="hover-amount"></span>'; function renderTimelineChart(drillCategory) {
const hoverName = detailsHeader.querySelector('.hover-name'); timelineDrillCategory = drillCategory;
const hoverAmount = detailsHeader.querySelector('.hover-amount');
let monthLabel, data; myChart.off('click');
if (monthIndex === null || monthIndex === undefined) { myChart.off('mouseover');
monthLabel = 'Все месяцы'; myChart.off('mouseout');
data = []; myChart.off('globalout');
for (const month of availableMonths) {
if (monthDataCache[month]) data.push(...monthDataCache[month]); 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] || [];
}
// 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;
}); });
hoverName.textContent = monthLabel; // Clean up previous zrender handlers
hoverAmount.textContent = formatRUB(total) + '\u202F₽'; if (window._timelineZrClickHandler) {
myChart.getZr().off('click', window._timelineZrClickHandler);
window._timelineZrClickHandler = null;
}
// Sort by value descending // Click handler: drill down into category, or go back from subcategory view
const sorted = Object.entries(catTotals).sort((a, b) => b[1] - a[1]); myChart.on('click', function(params) {
if (params.componentType === 'title') {
topItemsEl.innerHTML = ''; renderTimelineChart(null);
sorted.forEach(([cat, amount]) => { return;
const pct = ((amount / total) * 100).toFixed(1); }
const colorIdx = categoryOrder.indexOf(cat); if (params.componentType === 'series' && params.seriesName !== '__total__') {
const color = colorIdx !== -1 ? categoryColors[colorIdx] : '#999'; if (!drillCategory) {
renderTimelineChart(params.seriesName);
const item = document.createElement('div'); }
item.className = 'top-item'; }
item.innerHTML = `<span class="top-item-name"><span class="color-circle" style="background: ${color}"></span>${cat}</span><span class="top-item-percent">${pct}%</span><span class="top-item-amount">${formatRUB(amount)}\u202F₽</span>`;
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 // Switch between month (sunburst) and timeline (stacked bar) views
@ -614,34 +746,7 @@ function switchView(viewName) {
myChart.off('globalout'); myChart.off('globalout');
// Resize after layout change, then render stacked bar // Resize after layout change, then render stacked bar
myChart.resize(); myChart.resize();
myChart.setOption(buildTimelineOption(), true); renderTimelineChart(null);
// 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();
}
}
});
} else { } else {
container.classList.remove('timeline-mode'); container.classList.remove('timeline-mode');
// Remove all chart event listeners // Remove all chart event listeners

View File

@ -157,7 +157,8 @@ body {
/* Timeline mode modifiers */ /* Timeline mode modifiers */
.container.timeline-mode #center-label, .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; display: none !important;
} }
@ -170,20 +171,6 @@ body {
width: 100%; 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 { .content-wrapper {
display: flex; display: flex;
position: relative; position: relative;
@ -480,19 +467,6 @@ body {
width: 100%; 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 { .top-item-amount {
min-width: 80px; min-width: 80px;
} }