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:
parent
33a245728d
commit
b91aa65f61
281
app.js
281
app.js
@ -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
|
||||||
|
|||||||
30
styles.css
30
styles.css
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user