mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-03 08:33:26 +00:00
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:
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user