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 ( -