1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-10-21 19:00:12 +00:00

Optimize/statistics view responsive layout (#8285)

* make the statistics view responsive layout

* optimize

---------

Co-authored-by: zhouwenxuan <aries@Mac.local>
This commit is contained in:
Aries
2025-10-11 16:38:43 +08:00
committed by GitHub
parent 140a5768ce
commit 4856c8e1fc
4 changed files with 170 additions and 98 deletions

View File

@@ -1,23 +1,22 @@
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import useResizeObserver from '../useResizeObserver';
export const BarChart = ({ data, unit }) => {
const svgRef = useRef();
const containerRef = useRef();
const { width: containerW } = useResizeObserver(containerRef);
const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
useEffect(() => {
if (!data || data.length === 0) return;
const container = containerRef.current;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const containerWidth = container.offsetWidth;
const barWidth = 24;
const minBarSpacing = 24;
const totalBarWidth = barWidth + minBarSpacing;
const maxBarWidth = 24;
const minGap = 12;
const maxValue = d3.max(data, d => d.value);
const yAxisTickFormat = maxValue > 1000 ? d3.format('.1s') : d3.format('d'); // eg: 1200 -> 1.2k
@@ -31,30 +30,28 @@ export const BarChart = ({ data, unit }) => {
const margin = {
top: 15,
right: 30,
right: 20,
bottom: 60,
left: Math.max(20, maxLabelWidth + 8)
left: Math.max(28, maxLabelWidth + 10)
};
const requiredWidth = data.length * totalBarWidth + margin.left + margin.right;
const chartWidth = Math.max(containerWidth, requiredWidth);
const actualBarWidth = barWidth;
const width = chartWidth - margin.left - margin.right;
const width = Math.max(0, containerW - margin.left - margin.right);
const height = 250 - margin.top - margin.bottom;
const yScale = d3.scaleLinear().domain([0, maxValue]).range([height, 0]).nice();
const g = svg
.attr('width', chartWidth)
.attr('width', containerW)
.attr('height', 250)
.attr('viewBox', `0 0 ${containerW} 250`)
.attr('preserveAspectRatio', 'xMidYMid meet')
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const xScale = d3.scaleBand()
.domain(data.map(d => d.name))
.range([0, width])
.paddingInner(minBarSpacing / totalBarWidth)
.paddingInner(0.2)
.paddingOuter(0.1);
const yAxis = g.append('g')
.call(d3.axisLeft(yScale)
@@ -75,6 +72,9 @@ export const BarChart = ({ data, unit }) => {
yAxis.select('.domain').remove();
const bandwidth = xScale.bandwidth();
const actualBarWidth = Math.min(maxBarWidth, Math.max(4, bandwidth - minGap));
g.selectAll('.bar')
.data(data)
.enter()
@@ -82,25 +82,25 @@ export const BarChart = ({ data, unit }) => {
.attr('class', 'bar')
.attr('fill', '#ff9800')
.attr('d', d => {
const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2;
const x = xScale(d.name) + (bandwidth - actualBarWidth) / 2;
const width = actualBarWidth;
const height = 0;
const y = height;
const radius = Math.min(4, actualBarWidth / 6);
return `M${x},${y}
L${x},${y - height + radius}
Q${x},${y - height} ${x + radius},${y - height}
L${x + width - radius},${y - height}
Q${x + width},${y - height} ${x + width},${y - height + radius}
L${x + width},${y}
return `M${x},${y}
L${x},${y - height + radius}
Q${x},${y - height} ${x + radius},${y - height}
L${x + width - radius},${y - height}
Q${x + width},${y - height} ${x + width},${y - height + radius}
L${x + width},${y}
Z`;
})
.transition()
.duration(400)
.delay((d, i) => i * 100)
.attr('d', d => {
const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2;
const x = xScale(d.name) + (bandwidth - actualBarWidth) / 2;
const width = actualBarWidth;
const barHeight = height - yScale(d.value);
const y = height;
@@ -108,12 +108,12 @@ export const BarChart = ({ data, unit }) => {
if (barHeight <= 0) return '';
return `M${x},${y}
L${x},${y - barHeight + radius}
Q${x},${y - barHeight} ${x + radius},${y - barHeight}
L${x + width - radius},${y - barHeight}
Q${x + width},${y - barHeight} ${x + width},${y - barHeight + radius}
L${x + width},${y}
return `M${x},${y}
L${x},${y - barHeight + radius}
Q${x},${y - barHeight} ${x + radius},${y - barHeight}
L${x + width - radius},${y - barHeight}
Q${x + width},${y - barHeight} ${x + width},${y - barHeight + radius}
L${x + width},${y}
Z`;
});
@@ -195,7 +195,7 @@ export const BarChart = ({ data, unit }) => {
d3.select(this).transition().duration(200).attr('fill', '#ff9800');
});
}, [data, unit, isDark]);
}, [data, unit, isDark, containerW]);
const barWidth = 24;
const minBarSpacing = 24;
@@ -216,9 +216,9 @@ export const BarChart = ({ data, unit }) => {
overflowY: 'hidden',
}}>
<svg ref={svgRef} style={{
width: 'fit-content',
width: needsScrolling ? 'fit-content' : '100%',
height: '250px',
minWidth: '100%'
minWidth: needsScrolling ? 'auto' : '100%'
}}>
</svg>
</div>
@@ -228,18 +228,17 @@ export const BarChart = ({ data, unit }) => {
export const HorizontalBarChart = ({ data }) => {
const svgRef = useRef();
const containerRef = useRef();
const { width: containerW, height: containerH } = useResizeObserver(containerRef);
const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
useEffect(() => {
if (!data || data.length === 0) return;
const container = containerRef.current;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const containerWidth = container.offsetWidth;
const isMobile = containerWidth < 480;
const isMobile = containerW < 480;
const margin = {
top: 10,
right: isMobile ? 60 : 80,
@@ -247,18 +246,16 @@ export const HorizontalBarChart = ({ data }) => {
left: isMobile ? 100 : 120
};
const width = Math.max(350, containerWidth - margin.left - margin.right);
const width = Math.max(350, containerW - margin.left - margin.right);
const minBarSpacing = 26;
const barHeight = 18;
const totalItemHeight = barHeight + minBarSpacing;
const requiredContentHeight = data.length * totalItemHeight;
const containerHeight = container.offsetHeight || 340;
const availableHeight = containerHeight - margin.top - margin.bottom;
const availableHeight = containerH - margin.top - margin.bottom;
const shouldScroll = requiredContentHeight > availableHeight;
const svgHeight = shouldScroll ? requiredContentHeight + margin.top + margin.bottom : containerHeight;
const svgHeight = shouldScroll ? requiredContentHeight + margin.top + margin.bottom : containerH;
const actualContentHeight = shouldScroll ? requiredContentHeight : availableHeight;
const centerOffset = shouldScroll ? 0 : Math.max(0, (availableHeight - requiredContentHeight) / 2);
@@ -266,6 +263,8 @@ export const HorizontalBarChart = ({ data }) => {
const g = svg
.attr('width', '100%')
.attr('height', svgHeight)
.attr('viewBox', `0 0 ${containerW} ${svgHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet')
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
@@ -349,12 +348,12 @@ export const HorizontalBarChart = ({ data }) => {
if (width <= 0) return '';
return `M0,${y}
L${width - radius},${y}
Q${width},${y} ${width},${y + radius}
L${width},${y + height - radius}
Q${width},${y + height} ${width - radius},${y + height}
L0,${y + height}
return `M0,${y}
L${width - radius},${y}
Q${width},${y} ${width},${y + radius}
L${width},${y + height - radius}
Q${width},${y + height} ${width - radius},${y + height}
L0,${y + height}
Z`;
});
@@ -440,7 +439,7 @@ export const HorizontalBarChart = ({ data }) => {
.delay((d, i) => i * 100 + 400)
.style('opacity', 1);
}, [data, isDark]);
}, [data, isDark, containerW, containerH]);
const minBarSpacing = 26;
const barHeight = 18;

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import useResizeObserver from '../useResizeObserver';
const PieChart = ({ data }) => {
const svgRef = useRef();
const containerRef = useRef();
const { width: containerW } = useResizeObserver(containerRef);
const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
@@ -27,12 +29,22 @@ const PieChart = ({ data }) => {
.style('opacity', 0)
.style('z-index', '1000');
const containerWidth = container.offsetWidth;
const isMobile = containerWidth < 480;
const radius = 150;
const pieSize = (radius + 20) * 2;
const isMobile = containerW < 480;
const maxRadius = 150;
const padding = 20;
const legendWidth = Math.min(160, Math.max(120, containerW * 0.25));
// Always place legend on the right side of the pie
const radius = Math.max(60, Math.min(maxRadius, (containerW - legendWidth - padding * 2) / 2));
const pieSize = (radius + padding) * 2;
svg.attr('width', '100%').attr('height', pieSize);
const totalWidth = pieSize + legendWidth + padding;
const totalHeight = pieSize;
svg
.attr('width', '100%')
.attr('height', totalHeight)
.attr('viewBox', `0 0 ${totalWidth} ${totalHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
const g = svg.append('g')
.attr('transform', `translate(${pieSize / 2}, ${pieSize / 2})`);
@@ -143,9 +155,11 @@ const PieChart = ({ data }) => {
.text(labelText);
});
const legendX = pieSize + 15;
const legendY = (pieSize / 2 - (data.length * 11));
const legendContainer = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${pieSize + 15}, ${pieSize / 2 - (data.length * 11)})`);
.attr('transform', `translate(${legendX}, ${legendY})`);
const legend = legendContainer.selectAll('.legend-item')
.data(data)
@@ -173,7 +187,7 @@ const PieChart = ({ data }) => {
return label;
});
}, [data, isDark]);
}, [data, isDark, containerW]);
useEffect(() => {
const currentContainer = containerRef.current;
@@ -183,7 +197,7 @@ const PieChart = ({ data }) => {
}, []);
return (
<div ref={containerRef} style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', paddingLeft: '30px' }}>
<div ref={containerRef} style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', paddingLeft: '0' }}>
<svg ref={svgRef}></svg>
</div>
);

View File

@@ -7,14 +7,13 @@
.statistics-container {
width: 100%;
min-width: 1200px;
min-height: 100%;
height: max-content;
gap: 20px;
display: grid;
grid-template-columns: 550px 1fr auto;
grid-template-rows: 420px 340px;
grid-template-areas:
grid-template-columns: minmax(300px, 550px) minmax(0, 1fr) minmax(260px, auto);
grid-template-rows: minmax(360px, 420px) minmax(300px, 340px);
grid-template-areas:
"file-type creator creator"
"time time summary";
}
@@ -61,11 +60,12 @@
.chart-container.summary-chart-container {
grid-area: summary;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 24px;
width: fit-content;
align-items: stretch;
gap: 16px;
width: 100%;
}
.chart-header {
@@ -106,17 +106,18 @@
justify-content: center;
align-items: center;
min-height: 320px;
width: 100%;
}
.bar-chart-container {
height: 240px;
overflow-x: auto;
width: 100%;
}
.horizontal-bar-chart-container {
width: 100%;
height: 340px;
overflow-x: auto;
justify-content: center;
align-items: center;
}
@@ -126,7 +127,7 @@
height: 240px;
display: flex;
flex-direction: column;
min-width: 400px;
min-width: 0; /* allow shrink in nested flex/grid */
}
.bar-chart-responsive svg {
@@ -136,19 +137,23 @@
.card-container {
display: flex;
gap: 20px;
flex-direction: row; /* always row */
gap: 16px;
flex-wrap: nowrap; /* keep single row */
width: 100%;
overflow-x: auto;
}
.summary-card {
width: 220px;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 20px;
padding: 32px 16px;
background: #fef7ea;
border-radius: 10px;
width: 240px;
height: 240px;
flex-shrink: 0;
flex: 0 0 auto;
}
.summary-card:nth-child(2) {
@@ -319,13 +324,13 @@
min-height: 100%;
padding: 20px;
}
.statistics-container {
grid-template-columns: 1fr;
grid-template-rows: 420px 420px minmax(420px, auto) 420px;
grid-template-areas:
grid-template-rows: 420px 340px;
grid-template-areas:
"file-type"
"creator"
"creator"
"time"
"summary";
max-width: 550px;
@@ -335,21 +340,25 @@
}
.chart-container.summary-chart-container {
justify-content: space-between;
align-items: center;
padding: 40px 24px;
justify-content: flex-start;
align-items: stretch;
padding: 20px;
}
.chart-container.summary-chart-container .card-container {
justify-content: center;
}
.summary-card {
width: 240px;
height: 240px;
padding: 24px;
width: 220px;
height: 180px;
padding: 20px;
}
.summary-number {
font-size: 24px;
}
.summary-icon {
font-size: 28px;
width: 40px;
@@ -361,50 +370,51 @@
.statistics-view {
padding: 0;
}
.statistics-container {
padding: 16px;
gap: 16px;
max-width: 100%;
grid-template-rows: repeat(4, 380px);
grid-template-rows: 420px 340px;
}
.chart-container {
padding: 20px;
}
.chart-container h4 {
font-size: 14px;
margin-bottom: 16px;
padding-bottom: 8px;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.chart-container.summary-chart-container {
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 24px 20px;
}
.summary-card {
padding: 16px;
width: 100%;
max-width: 300px;
height: 100px;
padding: 20px;
align-self: center;
overflow-x: auto;
}
.summary-card {
width: 220px;
max-width: none;
height: 140px;
padding: 16px;
align-self: stretch;
}
.summary-number {
font-size: 20px;
}
.summary-icon {
font-size: 24px;
width: 36px;

View File

@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
// Observe element size changes and return { width, height }.
// Triggers updates when parent layout (like side panels) changes size.
export default function useResizeObserver(ref) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setSize({ width: Math.round(rect.width), height: Math.round(rect.height) });
let frame = null;
const update = (entry) => {
// Use contentRect for precise size; fallback to getBoundingClientRect
const cr = entry?.contentRect;
const w = Math.round((cr?.width ?? el.getBoundingClientRect().width));
const h = Math.round((cr?.height ?? el.getBoundingClientRect().height));
// Avoid layout thrash with rAF and skip no-op updates
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
setSize((prev) => (prev.width !== w || prev.height !== h ? { width: w, height: h } : prev));
});
};
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) update(entry);
});
ro.observe(el);
return () => {
if (frame) cancelAnimationFrame(frame);
ro.disconnect();
};
} else {
// Fallback: window resize listener
const onResize = () => update();
window.addEventListener('resize', onResize);
return () => {
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('resize', onResize);
};
}
}, [ref]);
return size;
}