feat: Added ChartLayout component + utils to correctly position Legends and Chart in the view (#10160)

* feat: added chartlayout component to render charts and legends

* fix: added fix for legenditemsSet calculation

* chore: added pulse frontend as codeowners for uplotv2

* chore: cleaned up the legend size calculations function

* chore: removed config from deps in charlayout

* fix: added fix for height calculation

* fix: pr review changes
This commit is contained in:
Abhi kumar
2026-02-02 17:27:14 +05:30
committed by GitHub
parent f6f8c78aaf
commit 2dcb817de1
6 changed files with 276 additions and 7 deletions

3
.github/CODEOWNERS vendored
View File

@@ -132,3 +132,6 @@
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## UplotV2
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend

View File

@@ -0,0 +1,131 @@
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
height: number;
legendWidth: number;
legendHeight: number;
legendsPerSet: number;
}
const AVG_CHAR_WIDTH = 8;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const LEGEND_GAP = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 36;
const MAX_LEGEND_WIDTH = 400;
/**
* Average text width from series labels (for legendsPerSet).
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH;
}
const averageLabelLength =
legends.reduce((sum, l) => sum + l.length, 0) / legends.length;
return averageLabelLength * AVG_CHAR_WIDTH;
}
/**
* Compute how much space to give to the chart area vs. the legend.
*
* - For a RIGHT legend, we reserve a vertical column on the right and shrink the chart width.
* - For a BOTTOM legend, we reserve up to two rows below the chart and shrink the chart height.
*
* Implementation details (high level):
* - Approximates legend item width from label text length, using a fixed average char width.
* - RIGHT legend:
* - `legendWidth` is clamped between 150px and min(MAX_LEGEND_WIDTH, 30% of container width).
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
* The returned values are the final chart and legend rectangles (width/height),
* plus `legendsPerSet` which hints how many legend items to show per row.
*/
export function calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig,
seriesLabels,
}: {
containerWidth: number;
containerHeight: number;
legendConfig: LegendConfig;
seriesLabels: string[];
}): ChartDimensions {
// Guard: no space to lay out chart or legend
if (containerWidth <= 0 || containerHeight <= 0) {
return {
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
legendsPerSet: 0,
};
}
// Approximate width of a single legend item based on label text.
const approxLegendItemWidth = calculateAverageLegendWidth(seriesLabels);
const legendItemCount = seriesLabels.length;
if (legendConfig.position === LegendPosition.RIGHT) {
const maxRightLegendWidth = Math.min(MAX_LEGEND_WIDTH, containerWidth * 0.3);
const rightLegendWidth = Math.min(
Math.max(150, approxLegendItemWidth),
maxRightLegendWidth,
);
return {
width: Math.max(0, containerWidth - rightLegendWidth),
height: containerHeight,
legendWidth: rightLegendWidth,
legendHeight: containerHeight,
// Single vertical list on the right.
legendsPerSet: 1,
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.min(approxLegendItemWidth, 400);
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
);
const legendRowCount = Math.min(
2,
Math.ceil(legendItemCount / legendItemsPerRow),
);
const idealBottomLegendHeight =
legendRowCount > 1
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,
maxAllowedLegendHeight,
);
// How many legend items per row in the Legend component.
const legendsPerSet = Math.ceil(
(containerWidth + LEGEND_GAP) /
(Math.min(MAX_LEGEND_WIDTH, approxLegendItemWidth) + LEGEND_GAP),
);
return {
width: containerWidth,
height: Math.max(0, containerHeight - bottomLegendHeight),
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
legendsPerSet,
};
}

View File

@@ -0,0 +1,39 @@
.chart-layout {
position: relative;
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
&--legend-right {
flex-direction: row;
.chart-layout__legend-wrapper {
padding-left: 0 !important;
padding-right: 12px !important;
}
}
&__legend-wrapper {
padding-left: 12px;
padding-bottom: 12px;
overflow: auto;
&::-webkit-scrollbar {
width: 0.25rem !important;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 0.5rem;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-300);
}
}
}

View File

@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'container/DashboardContainer/visualization/charts/utils';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import './ChartLayout.styles.scss';
export interface ChartLayoutProps {
legendComponent: (legendPerSet: number) => React.ReactNode;
children: (props: {
chartWidth: number;
chartHeight: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
containerWidth: number;
containerHeight: number;
legendConfig: LegendConfig;
config: UPlotConfigBuilder;
}
export default function ChartLayout({
legendComponent,
children,
layoutChildren,
containerWidth,
containerHeight,
legendConfig,
config,
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
const legendItemsMap = config.getLegendItems();
const seriesLabels = Object.values(legendItemsMap)
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
return calculateChartDimensions({
containerWidth,
containerHeight,
legendConfig,
seriesLabels,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerWidth, containerHeight, legendConfig],
);
return (
<div className="chart-layout__container">
<div
className={cx('chart-layout', {
'chart-layout--legend-right':
legendConfig.position === LegendPosition.RIGHT,
})}
>
<div className="chart-layout__content">
{children({
chartWidth: chartDimensions.width,
chartHeight: chartDimensions.height,
})}
</div>
<div
className="chart-layout__legend-wrapper"
style={{
height: chartDimensions.legendHeight,
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.legendsPerSet)}
</div>
</div>
{layoutChildren}
</div>
);
}

View File

@@ -12,6 +12,24 @@
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-virtuoso-container {
height: 100%;
width: 100%;
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
}
.legend-row {
@@ -89,3 +107,13 @@
background: rgba(255, 255, 255, 0.05);
}
}
.lightMode {
.legend-container {
.legend-virtuoso-container {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
}
}

View File

@@ -36,9 +36,6 @@ export default function Legend({
// Chunk legend items into rows of LEGENDS_PER_ROW items each
const legendRows = useMemo(() => {
const legendItems = Object.values(legendItemsMap);
if (legendsPerSet >= legendItems.length) {
return [legendItems];
}
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
if (i % legendsPerSet === 0) {
@@ -93,10 +90,7 @@ export default function Legend({
onMouseLeave={onLegendMouseLeave}
>
<Virtuoso
style={{
height: '100%',
width: '100%',
}}
className="legend-virtuoso-container"
data={legendRows}
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
/>