diff --git a/frontend/src/metadata/views/statistics/charts/BarChart.js b/frontend/src/metadata/views/statistics/charts/BarChart.js index c97c11ac34..bfaefc2347 100644 --- a/frontend/src/metadata/views/statistics/charts/BarChart.js +++ b/frontend/src/metadata/views/statistics/charts/BarChart.js @@ -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', }}> @@ -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; diff --git a/frontend/src/metadata/views/statistics/charts/PieChart.js b/frontend/src/metadata/views/statistics/charts/PieChart.js index dfc449b7a5..da2f8120a9 100644 --- a/frontend/src/metadata/views/statistics/charts/PieChart.js +++ b/frontend/src/metadata/views/statistics/charts/PieChart.js @@ -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 ( -
+
); diff --git a/frontend/src/metadata/views/statistics/index.css b/frontend/src/metadata/views/statistics/index.css index 506e6e6db2..9ebc8e531f 100644 --- a/frontend/src/metadata/views/statistics/index.css +++ b/frontend/src/metadata/views/statistics/index.css @@ -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; diff --git a/frontend/src/metadata/views/statistics/useResizeObserver.js b/frontend/src/metadata/views/statistics/useResizeObserver.js new file mode 100644 index 0000000000..a368f12baf --- /dev/null +++ b/frontend/src/metadata/views/statistics/useResizeObserver.js @@ -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; +}