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:
@@ -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,7 +82,7 @@ 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;
|
||||
@@ -100,7 +100,7 @@ export const BarChart = ({ data, unit }) => {
|
||||
.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;
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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;
|
||||
|
@@ -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>
|
||||
);
|
||||
|
@@ -7,13 +7,12 @@
|
||||
|
||||
.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-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) {
|
||||
@@ -322,7 +327,7 @@
|
||||
|
||||
.statistics-container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 420px 420px minmax(420px, auto) 420px;
|
||||
grid-template-rows: 420px 340px;
|
||||
grid-template-areas:
|
||||
"file-type"
|
||||
"creator"
|
||||
@@ -335,15 +340,19 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -366,7 +375,7 @@
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
grid-template-rows: repeat(4, 380px);
|
||||
grid-template-rows: 420px 340px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
@@ -380,7 +389,6 @@
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
@@ -390,15 +398,17 @@
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px 20px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 100px;
|
||||
padding: 20px;
|
||||
align-self: center;
|
||||
width: 220px;
|
||||
max-width: none;
|
||||
height: 140px;
|
||||
padding: 16px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.summary-number {
|
||||
|
49
frontend/src/metadata/views/statistics/useResizeObserver.js
Normal file
49
frontend/src/metadata/views/statistics/useResizeObserver.js
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user