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/pages/PublicDashboard/ @SigNoz/pulse-frontend
|
||||||
/frontend/src/container/PublicDashboardContainer/ @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 {
|
&:has(.legend-item-focused) .legend-item.legend-item-focused {
|
||||||
opacity: 1;
|
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 {
|
.legend-row {
|
||||||
@@ -89,3 +107,13 @@
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
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
|
// Chunk legend items into rows of LEGENDS_PER_ROW items each
|
||||||
const legendRows = useMemo(() => {
|
const legendRows = useMemo(() => {
|
||||||
const legendItems = Object.values(legendItemsMap);
|
const legendItems = Object.values(legendItemsMap);
|
||||||
if (legendsPerSet >= legendItems.length) {
|
|
||||||
return [legendItems];
|
|
||||||
}
|
|
||||||
|
|
||||||
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
|
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
|
||||||
if (i % legendsPerSet === 0) {
|
if (i % legendsPerSet === 0) {
|
||||||
@@ -93,10 +90,7 @@ export default function Legend({
|
|||||||
onMouseLeave={onLegendMouseLeave}
|
onMouseLeave={onLegendMouseLeave}
|
||||||
>
|
>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
style={{
|
className="legend-virtuoso-container"
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
data={legendRows}
|
data={legendRows}
|
||||||
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
|
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user