Responsive timeline layout: mobile legend below chart, orientation-aware breakpoints
- Move timeline legend from vertical top-right to horizontal bottom on portrait phones (<=850px + portrait) - Landscape phones and tablets use desktop layout (vertical legend on right) - CSS: cap chart-wrapper height with min(Xvw, calc(100dvh - 130px)) to prevent landscape overflow - CSS: timeline-mode fills viewport height in portrait for better space utilization - Rotate x-axis month labels to -90° on mobile for cleaner vertical display - Add debounced resize handler to re-render on orientation/breakpoint changes - Guard zrender click handler with y-bounds to prevent legend-zone false positives Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
646c5959d1
commit
f99bb963e4
170
app.js
170
app.js
@ -658,7 +658,7 @@ function buildTimelineOption(drillPath) {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: (params) => Math.round(totalPerMonth[params.dataIndex] / 1000) + 'к',
|
||||
fontSize: 14,
|
||||
fontSize: (window.innerWidth <= 850 && window.innerHeight > window.innerWidth) ? 12 : 14,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
|
||||
color: '#666'
|
||||
@ -670,12 +670,14 @@ function buildTimelineOption(drillPath) {
|
||||
|
||||
|
||||
const isDrilled = drillPath.length > 0;
|
||||
const isMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||||
|
||||
const chartTitle = isDrilled ? {
|
||||
text: '← ' + drillPath.join(' › '),
|
||||
left: 'center',
|
||||
top: 6,
|
||||
textStyle: {
|
||||
fontSize: 22,
|
||||
fontSize: isMobile ? 16 : 22,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
|
||||
color: '#333'
|
||||
@ -683,13 +685,15 @@ function buildTimelineOption(drillPath) {
|
||||
triggerEvent: true
|
||||
} : { show: false };
|
||||
|
||||
const legendTop = isDrilled ? 42 : 10;
|
||||
const legendRight = 10;
|
||||
const legendItemWidth = 12;
|
||||
const legendItemHeight = 12;
|
||||
const legendItemGap = 8;
|
||||
const legendPadding = [8, 12];
|
||||
const legendFontSize = 13;
|
||||
const legendItemWidth = isMobile ? 10 : 12;
|
||||
const legendItemHeight = isMobile ? 10 : 12;
|
||||
const legendItemGap = isMobile ? 6 : 8;
|
||||
const legendPadding = isMobile ? [4, 8] : [8, 12];
|
||||
const legendFontSize = isMobile ? 11 : 13;
|
||||
|
||||
const legendPosition = isMobile
|
||||
? { bottom: 20, left: 'center' }
|
||||
: { top: isDrilled ? 42 : 10, right: 10 };
|
||||
|
||||
return {
|
||||
backgroundColor: '#fff',
|
||||
@ -697,29 +701,28 @@ function buildTimelineOption(drillPath) {
|
||||
tooltip: { show: false },
|
||||
legend: {
|
||||
data: legendData,
|
||||
top: legendTop,
|
||||
right: legendRight,
|
||||
orient: 'vertical',
|
||||
...legendPosition,
|
||||
orient: isMobile ? 'horizontal' : 'vertical',
|
||||
itemWidth: legendItemWidth,
|
||||
itemHeight: legendItemHeight,
|
||||
textStyle: { fontSize: legendFontSize },
|
||||
itemGap: legendItemGap,
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
borderRadius: 6,
|
||||
backgroundColor: isMobile ? 'transparent' : 'rgba(255,255,255,0.85)',
|
||||
borderRadius: isMobile ? 0 : 6,
|
||||
padding: legendPadding,
|
||||
selector: false
|
||||
},
|
||||
grid: {
|
||||
left: 30,
|
||||
right: 180,
|
||||
top: isDrilled ? 55 : 40,
|
||||
bottom: 30,
|
||||
left: isMobile ? 10 : 30,
|
||||
right: isMobile ? 25 : 180,
|
||||
top: isMobile ? (isDrilled ? 40 : 10) : (isDrilled ? 55 : 40),
|
||||
bottom: isMobile ? 100 : 30,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xLabels,
|
||||
axisLabel: { fontSize: 13, rotate: -45 }
|
||||
axisLabel: { fontSize: isMobile ? 11 : 13, rotate: isMobile ? -90 : -45 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
@ -728,15 +731,17 @@ function buildTimelineOption(drillPath) {
|
||||
if (val >= 1000) return Math.round(val / 1000) + 'к\u202F₽';
|
||||
return val;
|
||||
},
|
||||
fontSize: 13
|
||||
fontSize: isMobile ? 11 : 13
|
||||
}
|
||||
},
|
||||
series: seriesList,
|
||||
_seriesDataMap: seriesDataMap,
|
||||
_monthCount: months.length,
|
||||
_legendLayout: {
|
||||
top: legendTop,
|
||||
right: legendRight,
|
||||
isMobile: isMobile,
|
||||
orient: isMobile ? 'horizontal' : 'vertical',
|
||||
top: legendPosition.top,
|
||||
right: legendPosition.right,
|
||||
itemWidth: legendItemWidth,
|
||||
itemHeight: legendItemHeight,
|
||||
itemGap: legendItemGap,
|
||||
@ -747,10 +752,12 @@ function buildTimelineOption(drillPath) {
|
||||
}
|
||||
|
||||
let timelineDrillPath = [];
|
||||
let _lastTimelineMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||||
|
||||
// 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) {
|
||||
_lastTimelineMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||||
drillPath = drillPath || [];
|
||||
historyAction = historyAction || 'push';
|
||||
timelineDrillPath = drillPath;
|
||||
@ -851,27 +858,77 @@ function renderTimelineChart(drillPath, historyAction, legendSelected) {
|
||||
allSelected = false;
|
||||
}
|
||||
});
|
||||
const legendFont = `${legendLayout.fontSize}px sans-serif`;
|
||||
const resetFontSize = Math.max(11, legendLayout.fontSize - 1);
|
||||
const resetFont = `${resetFontSize}px sans-serif`;
|
||||
|
||||
const resetLegendSelection = 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 }, '');
|
||||
};
|
||||
window._timelineResetLegend = allSelected ? null : resetLegendSelection;
|
||||
|
||||
const resetGraphic = {
|
||||
type: 'text',
|
||||
id: 'resetBtn',
|
||||
z: 100,
|
||||
zlevel: 1,
|
||||
style: {
|
||||
text: allSelected ? '' : '× сбросить',
|
||||
fontSize: resetFontSize,
|
||||
fill: '#999',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif'
|
||||
},
|
||||
cursor: 'pointer',
|
||||
onmouseover: function() {
|
||||
if (this && this.setStyle) this.setStyle({ fill: '#000' });
|
||||
},
|
||||
onmouseout: function() {
|
||||
if (this && this.setStyle) this.setStyle({ fill: '#999' });
|
||||
},
|
||||
onclick: resetLegendSelection
|
||||
};
|
||||
|
||||
const legendView = (myChart._componentsViews || []).find(v => v && v.__model && v.__model.mainType === 'legend');
|
||||
|
||||
if (legendLayout.isMobile) {
|
||||
// Mobile: center reset button below horizontal legend
|
||||
let resetTop;
|
||||
if (legendView && legendView.group) {
|
||||
const groupRect = legendView.group.getBoundingRect();
|
||||
resetTop = legendView.group.y + groupRect.y + groupRect.height + 2;
|
||||
}
|
||||
resetGraphic.left = 'center';
|
||||
if (resetTop !== undefined) {
|
||||
resetGraphic.top = resetTop;
|
||||
} else {
|
||||
resetGraphic.bottom = 2;
|
||||
}
|
||||
} else {
|
||||
// Desktop: position below vertical legend, left-aligned with legend box
|
||||
const legendFont = `${legendLayout.fontSize}px sans-serif`;
|
||||
const resetXOffset = 3;
|
||||
const maxLegendTextWidth = allNames.reduce((maxWidth, name) => {
|
||||
return Math.max(maxWidth, echarts.format.getTextRect(name, legendFont).width);
|
||||
}, 0);
|
||||
const iconTextGap = 5; // ECharts default icon/text gap for legend items
|
||||
const iconTextGap = 5;
|
||||
const legendBoxWidth =
|
||||
legendLayout.padding[1] * 2 +
|
||||
legendLayout.itemWidth +
|
||||
iconTextGap +
|
||||
maxLegendTextWidth;
|
||||
const resetTextWidth = echarts.format.getTextRect('× сбросить', resetFont).width;
|
||||
const resetTextWidth = echarts.format.getTextRect('× сбросить', `${resetFontSize}px sans-serif`).width;
|
||||
const resetRight =
|
||||
legendLayout.right +
|
||||
legendBoxWidth -
|
||||
legendLayout.padding[1] -
|
||||
resetTextWidth -
|
||||
resetXOffset;
|
||||
const legendView = (myChart._componentsViews || []).find(v => v && v.__model && v.__model.mainType === 'legend');
|
||||
const resetLeft = legendView && legendView.group ? legendView.group.x : null;
|
||||
let resetTop;
|
||||
if (legendView && legendView.group && legendView._contentGroup) {
|
||||
@ -896,46 +953,13 @@ function renderTimelineChart(drillPath, historyAction, legendSelected) {
|
||||
allNames.length * (legendLayout.itemHeight + legendLayout.itemGap);
|
||||
resetTop = nextRowTop + 11;
|
||||
}
|
||||
|
||||
const resetLegendSelection = 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 }, '');
|
||||
};
|
||||
window._timelineResetLegend = allSelected ? null : resetLegendSelection;
|
||||
|
||||
const resetGraphic = {
|
||||
type: 'text',
|
||||
id: 'resetBtn',
|
||||
top: resetTop,
|
||||
z: 100,
|
||||
zlevel: 1,
|
||||
style: {
|
||||
text: allSelected ? '' : '× сбросить',
|
||||
fontSize: resetFontSize,
|
||||
fill: '#999',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif'
|
||||
},
|
||||
cursor: 'pointer',
|
||||
onmouseover: function() {
|
||||
if (this && this.setStyle) this.setStyle({ fill: '#000' });
|
||||
},
|
||||
onmouseout: function() {
|
||||
if (this && this.setStyle) this.setStyle({ fill: '#999' });
|
||||
},
|
||||
onclick: resetLegendSelection
|
||||
};
|
||||
resetGraphic.top = resetTop;
|
||||
if (resetLeft !== null) {
|
||||
resetGraphic.left = resetLeft + resetXOffset;
|
||||
} else {
|
||||
resetGraphic.right = resetRight;
|
||||
}
|
||||
}
|
||||
|
||||
myChart.setOption({
|
||||
series: [{ name: '__total__', label: {
|
||||
@ -997,8 +1021,8 @@ function renderTimelineChart(drillPath, historyAction, legendSelected) {
|
||||
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) {
|
||||
// Click must be below the bar area (in the label zone) but above the legend
|
||||
if (y > yBottom + 5 && y < yBottom + 60) {
|
||||
const x = e.offsetX;
|
||||
// Find nearest bar index
|
||||
let bestIdx = -1, bestDist = Infinity;
|
||||
@ -2709,11 +2733,29 @@ async function initVisualization() {
|
||||
initVisualization();
|
||||
|
||||
// Handle window resize
|
||||
let _resizeDebounceTimer = null;
|
||||
window.addEventListener('resize', function() {
|
||||
if (currentView === 'month') {
|
||||
adjustChartSize();
|
||||
}
|
||||
myChart.resize();
|
||||
|
||||
// Re-render timeline when crossing the mobile breakpoint
|
||||
if (currentView === 'timeline') {
|
||||
const nowMobile = window.innerWidth <= 850 && window.innerHeight > window.innerWidth;
|
||||
if (nowMobile !== _lastTimelineMobile) {
|
||||
_lastTimelineMobile = nowMobile;
|
||||
clearTimeout(_resizeDebounceTimer);
|
||||
_resizeDebounceTimer = setTimeout(function() {
|
||||
const currentOption = myChart.getOption();
|
||||
const legendSelected = currentOption && currentOption.legend
|
||||
&& currentOption.legend[0] && currentOption.legend[0].selected
|
||||
? currentOption.legend[0].selected
|
||||
: null;
|
||||
renderTimelineChart(timelineDrillPath, 'replace', legendSelected);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Function to adjust chart size based on screen width
|
||||
|
||||
@ -449,7 +449,7 @@ body {
|
||||
|
||||
.chart-wrapper {
|
||||
width: 100%;
|
||||
height: 90lvw;
|
||||
height: min(90lvw, calc(100dvh - 130px));
|
||||
}
|
||||
|
||||
#details-box {
|
||||
@ -465,6 +465,7 @@ body {
|
||||
|
||||
.container.timeline-mode .chart-wrapper {
|
||||
width: 100%;
|
||||
height: calc(100dvh - 130px);
|
||||
}
|
||||
|
||||
.top-item-amount {
|
||||
@ -536,7 +537,7 @@ body {
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 95vw;
|
||||
height: min(95vw, calc(100dvh - 130px));
|
||||
}
|
||||
|
||||
#chart-container {
|
||||
@ -571,7 +572,7 @@ body {
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 100vw;
|
||||
height: min(100vw, calc(100dvh - 130px));
|
||||
}
|
||||
|
||||
.details-header {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user