From f99bb963e4e8d25c333aa5efdb628b523c0682a5 Mon Sep 17 00:00:00 2001 From: Anton Volnuhin Date: Sat, 7 Feb 2026 19:53:30 +0300 Subject: [PATCH] Responsive timeline layout: mobile legend below chart, orientation-aware breakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.js | 186 ++++++++++++++++++++++++++++++++--------------------- styles.css | 7 +- 2 files changed, 118 insertions(+), 75 deletions(-) diff --git a/app.js b/app.js index 59f6291..a7677ab 100644 --- a/app.js +++ b/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,51 +858,7 @@ 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 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 legendBoxWidth = - legendLayout.padding[1] * 2 + - legendLayout.itemWidth + - iconTextGap + - maxLegendTextWidth; - const resetTextWidth = echarts.format.getTextRect('× сбросить', resetFont).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) { - const legendItems = legendView._contentGroup.children(); - if (legendItems.length > 0) { - const lastItem = legendItems[legendItems.length - 1]; - const prevItem = legendItems.length > 1 ? legendItems[legendItems.length - 2] : null; - const rowStep = prevItem ? (lastItem.y - prevItem.y) : (legendLayout.itemHeight + legendLayout.itemGap); - const lastRect = lastItem.getBoundingRect(); - resetTop = - legendView.group.y + - (legendView._contentGroup.y || 0) + - lastItem.y + - lastRect.y + - rowStep; - } - } - if (resetTop === undefined) { - const nextRowTop = - legendLayout.top + - legendLayout.padding[0] + - allNames.length * (legendLayout.itemHeight + legendLayout.itemGap); - resetTop = nextRowTop + 11; - } const resetLegendSelection = function() { window._suppressLegendHistory = true; @@ -913,7 +876,6 @@ function renderTimelineChart(drillPath, historyAction, legendSelected) { const resetGraphic = { type: 'text', id: 'resetBtn', - top: resetTop, z: 100, zlevel: 1, style: { @@ -931,10 +893,72 @@ function renderTimelineChart(drillPath, historyAction, legendSelected) { }, onclick: resetLegendSelection }; - if (resetLeft !== null) { - resetGraphic.left = resetLeft + resetXOffset; + + 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 { - resetGraphic.right = resetRight; + // 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; + const legendBoxWidth = + legendLayout.padding[1] * 2 + + legendLayout.itemWidth + + iconTextGap + + maxLegendTextWidth; + const resetTextWidth = echarts.format.getTextRect('× сбросить', `${resetFontSize}px sans-serif`).width; + const resetRight = + legendLayout.right + + legendBoxWidth - + legendLayout.padding[1] - + resetTextWidth - + resetXOffset; + const resetLeft = legendView && legendView.group ? legendView.group.x : null; + let resetTop; + if (legendView && legendView.group && legendView._contentGroup) { + const legendItems = legendView._contentGroup.children(); + if (legendItems.length > 0) { + const lastItem = legendItems[legendItems.length - 1]; + const prevItem = legendItems.length > 1 ? legendItems[legendItems.length - 2] : null; + const rowStep = prevItem ? (lastItem.y - prevItem.y) : (legendLayout.itemHeight + legendLayout.itemGap); + const lastRect = lastItem.getBoundingRect(); + resetTop = + legendView.group.y + + (legendView._contentGroup.y || 0) + + lastItem.y + + lastRect.y + + rowStep; + } + } + if (resetTop === undefined) { + const nextRowTop = + legendLayout.top + + legendLayout.padding[0] + + allNames.length * (legendLayout.itemHeight + legendLayout.itemGap); + resetTop = nextRowTop + 11; + } + resetGraphic.top = resetTop; + if (resetLeft !== null) { + resetGraphic.left = resetLeft + resetXOffset; + } else { + resetGraphic.right = resetRight; + } } myChart.setOption({ @@ -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 diff --git a/styles.css b/styles.css index a448142..db9ead4 100644 --- a/styles.css +++ b/styles.css @@ -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 {