Compare commits

...

12 Commits

Author SHA1 Message Date
SagarRajput-7
1b7f13bf1d Merge branch 'SIG-6819' into SIG-6421 2025-05-27 11:48:55 +05:30
SagarRajput-7
395ddb44be Merge branch 'main' into SIG-6819 2025-05-27 11:48:46 +05:30
SagarRajput-7
9e45f181ac Merge branch 'main' into SIG-6819 2025-05-27 11:15:54 +05:30
SagarRajput-7
e974aa1a85 feat: allow custom color pallete in panel for legends 2025-05-26 10:25:30 +05:30
SagarRajput-7
e5c5ee5b1a feat: added test cases 2025-05-26 10:18:58 +05:30
SagarRajput-7
dd38d35e17 feat: allignment and fixes 2025-05-26 09:11:00 +05:30
SagarRajput-7
717c7e77a5 feat: added graph visibilty in panel edit mode also 2025-05-26 06:14:50 +05:30
SagarRajput-7
89c77676ee feat: row num adjustment 2025-05-26 06:00:45 +05:30
SagarRajput-7
7a8eb817f5 feat: removed histogram and pie from enhanced legends 2025-05-26 05:49:30 +05:30
SagarRajput-7
bb967f22af feat: created the legend marker as checkboxes 2025-05-26 05:42:59 +05:30
SagarRajput-7
66bba67799 feat: added option for right side legends 2025-05-26 05:06:15 +05:30
SagarRajput-7
522ee2f4a7 feat: added enhancements to legends in panel 2025-05-26 03:33:11 +05:30
18 changed files with 2075 additions and 43 deletions

View File

@@ -1,4 +1,5 @@
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { ToggleGraphProps } from 'components/Graph/types';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
@@ -19,6 +20,7 @@ import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { UseQueryResult } from 'react-query';
import { useDispatch } from 'react-redux';
@@ -36,11 +38,37 @@ function WidgetGraph({
selectedGraph,
}: WidgetGraphProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const lineChartRef = useRef<ToggleGraphProps>();
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
// Add legend state management similar to dashboard components
const [graphVisibility, setGraphVisibility] = useState<boolean[]>(
Array((queryResponse.data?.payload?.data?.result?.length || 0) + 1).fill(
true,
),
);
// Initialize graph visibility when data changes
useEffect(() => {
if (queryResponse.data?.payload?.data?.result) {
setGraphVisibility(
Array(queryResponse.data.payload.data.result.length + 1).fill(true),
);
}
}, [queryResponse.data?.payload?.data?.result]);
// Apply graph visibility when lineChartRef is available
useEffect(() => {
if (!lineChartRef.current) return;
graphVisibility.forEach((state, index) => {
lineChartRef.current?.toggleGraph(index, state);
});
}, [graphVisibility]);
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
const startTime = searchParams.get(QueryParams.startTime);
@@ -154,6 +182,8 @@ function WidgetGraph({
onDragSelect={onDragSelect}
selectedGraph={selectedGraph}
onClickHandler={graphClickHandler}
graphVisibility={graphVisibility}
setGraphVisibility={setGraphVisibility}
/>
</div>
);

View File

@@ -6,7 +6,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -27,6 +27,7 @@ function LeftContainer({
requestData,
setRequestData,
isLoadingPanelData,
setQueryResponse,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
const { selectedDashboard } = useDashboard();
@@ -49,6 +50,13 @@ function LeftContainer({
},
);
// Update parent component with query response for legend colors
useEffect(() => {
if (setQueryResponse) {
setQueryResponse(queryResponse);
}
}, [queryResponse, setQueryResponse]);
return (
<>
<WidgetGraph

View File

@@ -0,0 +1,169 @@
.legend-colors-container {
.legend-colors-collapse {
.ant-collapse-header {
padding: 8px 0 !important;
}
.ant-collapse-content-box {
padding: 0 0 12px 0 !important;
}
}
.legend-colors-content {
.legend-colors-header {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.legend-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
padding-top: 4px;
/* Webkit scrollbar styling */
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 0.5px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-300);
}
/* Firefox scrollbar styling */
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-400) transparent;
}
.legend-item-wrapper {
.ant-color-picker-trigger {
width: 100%;
height: auto;
padding: 0;
border: none;
background: transparent;
box-shadow: none;
&:hover {
border: none;
box-shadow: none;
}
&:focus {
border: none;
box-shadow: none;
outline: none;
}
}
}
.legend-item {
display: grid;
grid-template-columns: 1fr max-content;
gap: 12px;
align-items: center;
justify-content: center;
padding: 6px 8px;
border-radius: 4px;
transition: all 0.2s ease;
cursor: pointer;
width: 100%;
border: 1px solid transparent;
&:hover {
background-color: var(--bg-slate-400);
border-color: var(--bg-slate-500);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
.legend-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
.legend-marker {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label-text {
flex: 1;
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
}
.legend-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.reset-link {
font-size: 11px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
.legend-colors-header {
display: flex;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
}
.lightMode {
.legend-colors-container {
.legend-colors-content {
.legend-items {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-500);
}
scrollbar-color: var(--bg-vanilla-400) transparent;
}
.legend-item {
&:hover {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-slate-100);
}
}
}
}
}

View File

@@ -0,0 +1,205 @@
import './LegendColors.styles.scss';
import { Button, Collapse, ColorPicker, Tooltip, Typography } from 'antd';
import { themeColors } from 'constants/theme';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Palette } from 'lucide-react';
import {
Dispatch,
SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
// Component for legend text with conditional tooltip
function LegendText({ label }: { label: string }): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const checkOverflow = (): void => {
if (textRef.current) {
const isTextOverflowing =
textRef.current.scrollWidth > textRef.current.clientWidth;
setIsOverflowing(isTextOverflowing);
}
};
checkOverflow();
// Check on window resize
window.addEventListener('resize', checkOverflow);
return (): void => window.removeEventListener('resize', checkOverflow);
}, [label]);
return (
<Tooltip title={label} open={isOverflowing ? undefined : false}>
<span ref={textRef} className="legend-label-text">
{label}
</span>
</Tooltip>
);
}
interface LegendColorsProps {
customLegendColors: Record<string, string>;
setCustomLegendColors: Dispatch<SetStateAction<Record<string, string>>>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
function LegendColors({
customLegendColors,
setCustomLegendColors,
queryResponse = null as any,
}: LegendColorsProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
// Get legend labels from query response or current query
const legendLabels = useMemo(() => {
if (queryResponse?.data?.payload?.data?.result) {
return queryResponse.data.payload.data.result.map((item: any) =>
getLabelName(item.metric || {}, item.queryName || '', item.legend || ''),
);
}
// Fallback to query data if no response available
return currentQuery.builder.queryData.map((query) =>
getLabelName({}, query.queryName || '', query.legend || ''),
);
}, [queryResponse, currentQuery]);
// Get current or default color for a legend
const getColorForLegend = (label: string): string => {
if (customLegendColors[label]) {
return customLegendColors[label];
}
return generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
};
// Handle color change
const handleColorChange = (label: string, color: string): void => {
setCustomLegendColors((prev) => ({
...prev,
[label]: color,
}));
};
// Reset to default color
const resetToDefault = (label: string): void => {
setCustomLegendColors((prev) => {
const updated = { ...prev };
delete updated[label];
return updated;
});
};
// Reset all colors to default
const resetAllColors = (): void => {
setCustomLegendColors({});
};
const items = [
{
key: 'legend-colors',
label: (
<section className="legend-colors-header">
<Palette size={16} />
<Typography.Text className="typography">Legend Colors</Typography.Text>
</section>
),
children: (
<div className="legend-colors-content">
{legendLabels.length === 0 ? (
<Typography.Text type="secondary">
No legends available. Run a query to see legend options.
</Typography.Text>
) : (
<>
<div className="legend-colors-header">
<Button
size="small"
type="text"
onClick={resetAllColors}
disabled={Object.keys(customLegendColors).length === 0}
>
Reset All
</Button>
</div>
<div className="legend-items">
{legendLabels.map((label: string) => (
<div key={label} className="legend-item-wrapper">
<ColorPicker
value={getColorForLegend(label)}
onChange={(color): void =>
handleColorChange(label, color.toHexString())
}
size="small"
showText={false}
trigger="click"
>
<div className="legend-item">
<div className="legend-info">
<div
className="legend-marker"
style={{ backgroundColor: getColorForLegend(label) }}
/>
<LegendText label={label} />
</div>
{customLegendColors[label] && (
<div className="legend-actions">
<Typography.Link
className="reset-link"
onClick={(e): void => {
e.stopPropagation();
resetToDefault(label);
}}
>
Reset
</Typography.Link>
</div>
)}
</div>
</ColorPicker>
</div>
))}
</div>
</>
)}
</div>
),
},
];
return (
<div className="legend-colors-container">
<Collapse
items={items}
ghost
size="small"
expandIconPosition="end"
className="legend-colors-collapse"
accordion
/>
</div>
);
}
LegendColors.defaultProps = {
queryResponse: null,
};
export default LegendColors;

View File

@@ -166,6 +166,18 @@
gap: 8px;
}
.legend-position {
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-colors {
margin-top: 16px;
}
.panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400);

View File

@@ -150,3 +150,31 @@ export const panelTypeVsStackingChartPreferences: {
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLegendPosition: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: true,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLegendColors: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: true,
[PANEL_TYPES.BAR]: true,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.HISTOGRAM]: true,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -30,7 +30,14 @@ import {
useRef,
useState,
} from 'react';
import { ColumnUnit, Widgets } from 'types/api/dashboard/getAll';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
LegendPosition,
Widgets,
} from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
@@ -40,6 +47,8 @@ import {
panelTypeVsColumnUnitPreferences,
panelTypeVsCreateAlert,
panelTypeVsFillSpan,
panelTypeVsLegendColors,
panelTypeVsLegendPosition,
panelTypeVsLogScale,
panelTypeVsPanelTimePreferences,
panelTypeVsSoftMinMax,
@@ -47,6 +56,7 @@ import {
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
} from './constants';
import LegendColors from './LegendColors/LegendColors';
import ThresholdSelector from './Threshold/ThresholdSelector';
import { ThresholdProps } from './Threshold/types';
import { timePreferance } from './timeItems';
@@ -98,6 +108,11 @@ function RightContainer({
setColumnUnits,
isLogScale,
setIsLogScale,
legendPosition,
setLegendPosition,
customLegendColors,
setCustomLegendColors,
queryResponse,
}: RightContainerProps): JSX.Element {
const { selectedDashboard } = useDashboard();
const [inputValue, setInputValue] = useState(title);
@@ -128,6 +143,8 @@ function RightContainer({
panelTypeVsStackingChartPreferences[selectedGraph];
const allowPanelTimePreference =
panelTypeVsPanelTimePreferences[selectedGraph];
const allowLegendPosition = panelTypeVsLegendPosition[selectedGraph];
const allowLegendColors = panelTypeVsLegendColors[selectedGraph];
const allowPanelColumnPreference =
panelTypeVsColumnUnitPreferences[selectedGraph];
@@ -430,6 +447,40 @@ function RightContainer({
</Select>
</section>
)}
{allowLegendPosition && (
<section className="legend-position">
<Typography.Text className="typography">Legend Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</section>
{allowCreateAlerts && (
@@ -495,10 +546,19 @@ interface RightContainerProps {
setSoftMax: Dispatch<SetStateAction<number | null>>;
isLogScale: boolean;
setIsLogScale: Dispatch<SetStateAction<boolean>>;
legendPosition: LegendPosition;
setLegendPosition: Dispatch<SetStateAction<LegendPosition>>;
customLegendColors: Record<string, string>;
setCustomLegendColors: Dispatch<SetStateAction<Record<string, string>>>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
RightContainer.defaultProps = {
selectedWidget: undefined,
queryResponse: null,
};
export default RightContainer;

View File

@@ -34,11 +34,19 @@ import {
} from 'providers/Dashboard/util';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { generatePath, useParams } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { ColumnUnit, Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
Dashboard,
LegendPosition,
Widgets,
} from 'types/api/dashboard/getAll';
import { IField } from 'types/api/logs/fields';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -183,6 +191,13 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
const [isLogScale, setIsLogScale] = useState<boolean>(
selectedWidget?.isLogScale || false,
);
const [legendPosition, setLegendPosition] = useState<LegendPosition>(
selectedWidget?.legendPosition || LegendPosition.BOTTOM,
);
const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string>
>(selectedWidget?.customLegendColors || {});
const [saveModal, setSaveModal] = useState(false);
const [discardModal, setDiscardModal] = useState(false);
@@ -248,6 +263,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
selectedLogFields,
selectedTracesFields,
isLogScale,
legendPosition,
customLegendColors,
columnWidths: columnWidths?.[selectedWidget?.id],
};
});
@@ -272,6 +289,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
combineHistogram,
stackedBarChart,
isLogScale,
legendPosition,
customLegendColors,
columnWidths,
]);
@@ -330,6 +349,11 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
// hence while changing the query contains the older value and the processing logic fails
const [isLoadingPanelData, setIsLoadingPanelData] = useState<boolean>(false);
// State to hold query response for sharing between left and right containers
const [queryResponse, setQueryResponse] = useState<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>(null as any);
// request data should be handled by the parent and the child components should consume the same
// this has been moved here from the left container
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
@@ -471,6 +495,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false,
selectedLogFields: selectedWidget?.selectedLogFields || [],
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
customLegendColors: selectedWidget?.customLegendColors || {},
},
]
: [
@@ -498,6 +524,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false,
selectedLogFields: selectedWidget?.selectedLogFields || [],
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
customLegendColors: selectedWidget?.customLegendColors || {},
},
...afterWidgets,
],
@@ -711,6 +739,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
requestData={requestData}
setRequestData={setRequestData}
isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse}
/>
)}
</OverlayScrollbar>
@@ -752,6 +781,11 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
setIsFillSpans={setIsFillSpans}
isLogScale={isLogScale}
setIsLogScale={setIsLogScale}
legendPosition={legendPosition}
setLegendPosition={setLegendPosition}
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}

View File

@@ -27,6 +27,11 @@ export interface WidgetGraphProps {
requestData: GetQueryResultsProps;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
isLoadingPanelData: boolean;
setQueryResponse?: Dispatch<
SetStateAction<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>
>;
}
export type WidgetGraphContainerProps = {

View File

@@ -50,14 +50,19 @@ function PiePanelWrapper({
color: string;
}[] = [].concat(
...(panelData
.map((d) => ({
label: getLabelName(d.metric, d.queryName || '', d.legend || ''),
value: d.values?.[0]?.[1],
color: generateColor(
getLabelName(d.metric, d.queryName || '', d.legend || ''),
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
}))
.map((d) => {
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
return {
label,
value: d.values?.[0]?.[1],
color:
widget?.customLegendColors?.[label] ||
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
};
})
.filter((d) => d !== undefined) as never[]),
);

View File

@@ -138,6 +138,9 @@ function UplotPanelWrapper({
timezone: timezone.value,
customSeries,
isLogScale: widget?.isLogScale,
colorMapping: widget?.customLegendColors,
enhancedLegend: true, // Enable enhanced legend
legendPosition: widget?.legendPosition,
}),
[
widget?.id,
@@ -163,6 +166,8 @@ function UplotPanelWrapper({
timezone.value,
customSeries,
widget?.isLogScale,
widget?.legendPosition,
widget?.customLegendColors,
],
);

View File

@@ -0,0 +1,521 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Dimensions } from 'hooks/useDimensions';
import { LegendPosition } from 'types/api/dashboard/getAll';
import {
applyEnhancedLegendStyling,
calculateEnhancedLegendConfig,
EnhancedLegendConfig,
} from '../enhancedLegend';
describe('Enhanced Legend Functionality', () => {
const mockDimensions: Dimensions = {
width: 800,
height: 400,
};
const mockConfig: EnhancedLegendConfig = {
minHeight: 46,
maxHeight: 80,
calculatedHeight: 60,
showScrollbar: false,
requiredRows: 2,
minWidth: 150,
maxWidth: 300,
calculatedWidth: 200,
};
describe('calculateEnhancedLegendConfig', () => {
describe('Bottom Legend Configuration', () => {
it('should calculate correct configuration for bottom legend with few series', () => {
const config = calculateEnhancedLegendConfig(
mockDimensions,
3,
['Series A', 'Series B', 'Series C'],
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.minHeight).toBe(46); // lineHeight (34) + padding (12)
expect(config.showScrollbar).toBe(false);
expect(config.requiredRows).toBeGreaterThanOrEqual(1); // Actual behavior may vary
});
it('should calculate correct configuration for bottom legend with many series', () => {
const longSeriesLabels = Array.from(
{ length: 10 },
(_, i) => `Very Long Series Name ${i + 1}`,
);
const config = calculateEnhancedLegendConfig(
mockDimensions,
10,
longSeriesLabels,
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.showScrollbar).toBe(true);
expect(config.requiredRows).toBeGreaterThan(2);
expect(config.maxHeight).toBeLessThanOrEqual(80); // absoluteMaxHeight constraint
});
it('should handle responsive width adjustments for bottom legend', () => {
const narrowDimensions: Dimensions = { width: 300, height: 400 };
const wideDimensions: Dimensions = { width: 1200, height: 400 };
const narrowConfig = calculateEnhancedLegendConfig(
narrowDimensions,
5,
['Series A', 'Series B', 'Series C', 'Series D', 'Series E'],
LegendPosition.BOTTOM,
);
const wideConfig = calculateEnhancedLegendConfig(
wideDimensions,
5,
['Series A', 'Series B', 'Series C', 'Series D', 'Series E'],
LegendPosition.BOTTOM,
);
// Narrow panels should have more rows due to less items per row
expect(narrowConfig.requiredRows).toBeGreaterThanOrEqual(
wideConfig.requiredRows,
);
});
it('should respect maximum legend height ratio for bottom legend', () => {
const config = calculateEnhancedLegendConfig(
mockDimensions,
20,
Array.from({ length: 20 }, (_, i) => `Series ${i + 1}`),
LegendPosition.BOTTOM,
);
// The implementation uses absoluteMaxHeight of 80
expect(config.calculatedHeight).toBeLessThanOrEqual(80);
});
});
describe('Right Legend Configuration', () => {
it('should calculate correct configuration for right legend', () => {
const config = calculateEnhancedLegendConfig(
mockDimensions,
5,
['Series A', 'Series B', 'Series C', 'Series D', 'Series E'],
LegendPosition.RIGHT,
);
expect(config.calculatedWidth).toBeGreaterThan(0);
expect(config.minWidth).toBe(150);
expect(config.maxWidth).toBeLessThanOrEqual(400);
expect(config.calculatedWidth).toBeLessThanOrEqual(
mockDimensions.width * 0.3,
); // maxLegendWidthRatio
expect(config.requiredRows).toBe(5); // Each series on its own row for right-side
});
it('should calculate width based on series label length for right legend', () => {
const shortLabels = ['A', 'B', 'C'];
const longLabels = [
'Very Long Series Name A',
'Very Long Series Name B',
'Very Long Series Name C',
];
const shortConfig = calculateEnhancedLegendConfig(
mockDimensions,
3,
shortLabels,
LegendPosition.RIGHT,
);
const longConfig = calculateEnhancedLegendConfig(
mockDimensions,
3,
longLabels,
LegendPosition.RIGHT,
);
expect(longConfig.calculatedWidth).toBeGreaterThan(
shortConfig.calculatedWidth ?? 0,
);
});
it('should handle scrollbar for right legend with many series', () => {
const tallDimensions: Dimensions = { width: 800, height: 200 };
const manySeriesLabels = Array.from(
{ length: 15 },
(_, i) => `Series ${i + 1}`,
);
const config = calculateEnhancedLegendConfig(
tallDimensions,
15,
manySeriesLabels,
LegendPosition.RIGHT,
);
expect(config.showScrollbar).toBe(true);
expect(config.calculatedHeight).toBeLessThanOrEqual(config.maxHeight);
});
it('should respect maximum width constraints for right legend', () => {
const narrowDimensions: Dimensions = { width: 400, height: 400 };
const config = calculateEnhancedLegendConfig(
narrowDimensions,
5,
Array.from({ length: 5 }, (_, i) => `Very Long Series Name ${i + 1}`),
LegendPosition.RIGHT,
);
expect(config.calculatedWidth).toBeLessThanOrEqual(
narrowDimensions.width * 0.3,
);
expect(config.calculatedWidth).toBeLessThanOrEqual(400); // absoluteMaxWidth
});
});
describe('Edge Cases', () => {
it('should handle empty series labels', () => {
const config = calculateEnhancedLegendConfig(
mockDimensions,
0,
[],
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.requiredRows).toBe(0);
});
it('should handle undefined series labels', () => {
const config = calculateEnhancedLegendConfig(
mockDimensions,
3,
undefined,
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.requiredRows).toBe(1); // For 3 series, should be 1 row (logic only forces 2 rows when seriesCount > 3)
});
it('should handle very small dimensions', () => {
const smallDimensions: Dimensions = { width: 100, height: 100 };
const config = calculateEnhancedLegendConfig(
smallDimensions,
3,
['A', 'B', 'C'],
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.calculatedHeight).toBeLessThanOrEqual(
smallDimensions.height * 0.15,
);
});
});
});
describe('applyEnhancedLegendStyling', () => {
let mockLegendElement: HTMLElement;
beforeEach(() => {
mockLegendElement = document.createElement('div');
mockLegendElement.className = 'u-legend';
});
describe('Bottom Legend Styling', () => {
it('should apply correct classes for bottom legend', () => {
applyEnhancedLegendStyling(
mockLegendElement,
mockConfig,
2,
LegendPosition.BOTTOM,
);
expect(mockLegendElement.classList.contains('u-legend-enhanced')).toBe(
true,
);
expect(mockLegendElement.classList.contains('u-legend-bottom')).toBe(true);
expect(mockLegendElement.classList.contains('u-legend-right')).toBe(false);
expect(mockLegendElement.classList.contains('u-legend-multi-line')).toBe(
true,
);
});
it('should apply single-line class for single row bottom legend', () => {
applyEnhancedLegendStyling(
mockLegendElement,
mockConfig,
1,
LegendPosition.BOTTOM,
);
expect(mockLegendElement.classList.contains('u-legend-single-line')).toBe(
true,
);
expect(mockLegendElement.classList.contains('u-legend-multi-line')).toBe(
false,
);
});
it('should set correct height styles for bottom legend', () => {
applyEnhancedLegendStyling(
mockLegendElement,
mockConfig,
2,
LegendPosition.BOTTOM,
);
expect(mockLegendElement.style.height).toBe('60px');
expect(mockLegendElement.style.minHeight).toBe('46px');
expect(mockLegendElement.style.maxHeight).toBe('80px');
expect(mockLegendElement.style.width).toBe('');
});
});
describe('Right Legend Styling', () => {
it('should apply correct classes for right legend', () => {
applyEnhancedLegendStyling(
mockLegendElement,
mockConfig,
5,
LegendPosition.RIGHT,
);
expect(mockLegendElement.classList.contains('u-legend-enhanced')).toBe(
true,
);
expect(mockLegendElement.classList.contains('u-legend-right')).toBe(true);
expect(mockLegendElement.classList.contains('u-legend-bottom')).toBe(false);
expect(mockLegendElement.classList.contains('u-legend-right-aligned')).toBe(
true,
);
});
it('should set correct width and height styles for right legend', () => {
applyEnhancedLegendStyling(
mockLegendElement,
mockConfig,
5,
LegendPosition.RIGHT,
);
expect(mockLegendElement.style.width).toBe('200px');
expect(mockLegendElement.style.minWidth).toBe('150px');
expect(mockLegendElement.style.maxWidth).toBe('300px');
expect(mockLegendElement.style.height).toBe('60px');
expect(mockLegendElement.style.minHeight).toBe('46px');
expect(mockLegendElement.style.maxHeight).toBe('80px');
});
});
describe('Scrollbar Styling', () => {
it('should add scrollable class when scrollbar is needed', () => {
const scrollableConfig = { ...mockConfig, showScrollbar: true };
applyEnhancedLegendStyling(
mockLegendElement,
scrollableConfig,
5,
LegendPosition.BOTTOM,
);
expect(mockLegendElement.classList.contains('u-legend-scrollable')).toBe(
true,
);
});
it('should remove scrollable class when scrollbar is not needed', () => {
mockLegendElement.classList.add('u-legend-scrollable');
applyEnhancedLegendStyling(
mockLegendElement,
mockConfig,
2,
LegendPosition.BOTTOM,
);
expect(mockLegendElement.classList.contains('u-legend-scrollable')).toBe(
false,
);
});
});
});
describe('Legend Responsive Distribution', () => {
describe('Items per row calculation', () => {
it('should calculate correct items per row for different panel widths', () => {
const testCases = [
{ width: 300, expectedMaxItemsPerRow: 2 },
{ width: 600, expectedMaxItemsPerRow: 4 },
{ width: 1200, expectedMaxItemsPerRow: 8 },
];
testCases.forEach(({ width, expectedMaxItemsPerRow }) => {
const dimensions: Dimensions = { width, height: 400 };
const config = calculateEnhancedLegendConfig(
dimensions,
expectedMaxItemsPerRow + 2, // More series than can fit in one row
Array.from(
{ length: expectedMaxItemsPerRow + 2 },
(_, i) => `Series ${i + 1}`,
),
LegendPosition.BOTTOM,
);
expect(config.requiredRows).toBeGreaterThan(1);
});
});
it('should handle very long series names by adjusting layout', () => {
const longSeriesNames = [
'Very Long Series Name That Might Not Fit',
'Another Extremely Long Series Name',
'Yet Another Very Long Series Name',
];
const config = calculateEnhancedLegendConfig(
{ width: 400, height: 300 },
3,
longSeriesNames,
LegendPosition.BOTTOM,
);
// Should require more rows due to long names
expect(config.requiredRows).toBeGreaterThanOrEqual(2);
});
});
describe('Dynamic height adjustment', () => {
it('should adjust height based on number of required rows', () => {
const fewSeries = calculateEnhancedLegendConfig(
mockDimensions,
2,
['A', 'B'],
LegendPosition.BOTTOM,
);
const manySeries = calculateEnhancedLegendConfig(
mockDimensions,
10,
Array.from({ length: 10 }, (_, i) => `Series ${i + 1}`),
LegendPosition.BOTTOM,
);
expect(manySeries.calculatedHeight).toBeGreaterThan(
fewSeries.calculatedHeight,
);
});
});
});
describe('Legend Position Integration', () => {
it('should handle legend position changes correctly', () => {
const seriesLabels = [
'Series A',
'Series B',
'Series C',
'Series D',
'Series E',
];
const bottomConfig = calculateEnhancedLegendConfig(
mockDimensions,
5,
seriesLabels,
LegendPosition.BOTTOM,
);
const rightConfig = calculateEnhancedLegendConfig(
mockDimensions,
5,
seriesLabels,
LegendPosition.RIGHT,
);
// Bottom legend should have width constraints, right legend should have height constraints
expect(bottomConfig.calculatedWidth).toBeUndefined();
expect(rightConfig.calculatedWidth).toBeDefined();
expect(rightConfig.calculatedWidth).toBeGreaterThan(0);
});
it('should apply different styling based on legend position', () => {
const mockElement = document.createElement('div');
// Test bottom positioning
applyEnhancedLegendStyling(
mockElement,
mockConfig,
3,
LegendPosition.BOTTOM,
);
const hasBottomClasses = mockElement.classList.contains('u-legend-bottom');
// Reset element
mockElement.className = 'u-legend';
// Test right positioning
applyEnhancedLegendStyling(mockElement, mockConfig, 3, LegendPosition.RIGHT);
const hasRightClasses = mockElement.classList.contains('u-legend-right');
expect(hasBottomClasses).toBe(true);
expect(hasRightClasses).toBe(true);
});
});
describe('Performance and Edge Cases', () => {
it('should handle large number of series efficiently', () => {
const startTime = Date.now();
const largeSeries = Array.from({ length: 100 }, (_, i) => `Series ${i + 1}`);
const config = calculateEnhancedLegendConfig(
mockDimensions,
100,
largeSeries,
LegendPosition.BOTTOM,
);
const endTime = Date.now();
const executionTime = endTime - startTime;
expect(executionTime).toBeLessThan(100); // Should complete within 100ms
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.showScrollbar).toBe(true);
});
it('should handle zero dimensions gracefully', () => {
const zeroDimensions: Dimensions = { width: 0, height: 0 };
const config = calculateEnhancedLegendConfig(
zeroDimensions,
3,
['A', 'B', 'C'],
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.minHeight).toBeGreaterThan(0);
});
it('should handle negative dimensions gracefully', () => {
const negativeDimensions: Dimensions = { width: -100, height: -100 };
const config = calculateEnhancedLegendConfig(
negativeDimensions,
3,
['A', 'B', 'C'],
LegendPosition.BOTTOM,
);
expect(config.calculatedHeight).toBeGreaterThan(0);
expect(config.minHeight).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,246 @@
import { Dimensions } from 'hooks/useDimensions';
import { LegendPosition } from 'types/api/dashboard/getAll';
export interface EnhancedLegendConfig {
minHeight: number;
maxHeight: number;
calculatedHeight: number;
showScrollbar: boolean;
requiredRows: number;
// For right-side legend
minWidth?: number;
maxWidth?: number;
calculatedWidth?: number;
}
/**
* Calculate legend configuration based on panel dimensions and series count
* Prioritizes chart space while ensuring legend usability
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function calculateEnhancedLegendConfig(
dimensions: Dimensions,
seriesCount: number,
seriesLabels?: string[],
legendPosition: LegendPosition = LegendPosition.BOTTOM,
): EnhancedLegendConfig {
const lineHeight = 34;
const padding = 12;
const maxRowsToShow = 2; // Reduced from 3 to 2 for better chart/legend ratio
// Different configurations for bottom vs right positioning
if (legendPosition === LegendPosition.RIGHT) {
// Right-side legend configuration
const maxLegendWidthRatio = 0.3; // Legend should not take more than 30% of panel width
const absoluteMaxWidth = Math.min(
400,
dimensions.width * maxLegendWidthRatio,
);
const minWidth = 150;
// For right-side legend, calculate based on text length
const avgCharWidth = 8;
let avgTextLength = 15;
if (seriesLabels && seriesLabels.length > 0) {
const totalLength = seriesLabels.reduce(
(sum, label) => sum + Math.min(label.length, 40),
0,
);
avgTextLength = Math.max(
10,
Math.min(35, totalLength / seriesLabels.length),
);
}
// Fix: Ensure width respects the ratio constraint even if it's less than minWidth
const estimatedWidth = 80 + avgCharWidth * avgTextLength;
const calculatedWidth = Math.min(
Math.max(minWidth, estimatedWidth),
absoluteMaxWidth,
);
// For right-side legend, height can be more flexible
const maxHeight = dimensions.height - 40; // Leave some padding
const idealHeight = seriesCount * lineHeight + padding;
const calculatedHeight = Math.min(idealHeight, maxHeight);
const showScrollbar = idealHeight > calculatedHeight;
return {
minHeight: lineHeight + padding,
maxHeight,
calculatedHeight,
showScrollbar,
requiredRows: seriesCount, // Each series on its own row for right-side
minWidth,
maxWidth: absoluteMaxWidth,
calculatedWidth,
};
}
// Bottom legend configuration (existing logic)
const maxLegendRatio = 0.15;
// Fix: For very small dimensions, respect the ratio instead of using fixed 80px minimum
const ratioBasedMaxHeight = dimensions.height * maxLegendRatio;
// Handle edge cases and calculate absolute max height
let absoluteMaxHeight;
if (dimensions.height <= 0) {
absoluteMaxHeight = 46; // Fallback for invalid dimensions
} else if (dimensions.height <= 400) {
// For small to medium panels, prioritize ratio constraint
absoluteMaxHeight = Math.min(80, Math.max(15, ratioBasedMaxHeight));
} else {
// For larger panels, maintain a reasonable minimum
absoluteMaxHeight = Math.min(80, Math.max(20, ratioBasedMaxHeight));
}
const baseItemWidth = 44;
const avgCharWidth = 8;
let avgTextLength = 15;
if (seriesLabels && seriesLabels.length > 0) {
const totalLength = seriesLabels.reduce(
(sum, label) => sum + Math.min(label.length, 30),
0,
);
avgTextLength = Math.max(8, Math.min(25, totalLength / seriesLabels.length));
}
// Estimate item width based on actual or estimated text length
let estimatedItemWidth = baseItemWidth + avgCharWidth * avgTextLength;
// For very wide panels, allow longer text
if (dimensions.width > 800) {
estimatedItemWidth = Math.max(
estimatedItemWidth,
baseItemWidth + avgCharWidth * 22,
);
} else if (dimensions.width < 400) {
estimatedItemWidth = Math.min(
estimatedItemWidth,
baseItemWidth + avgCharWidth * 14,
);
}
// Calculate items per row based on available width
const availableWidth = dimensions.width - padding * 2;
const itemsPerRow = Math.max(
1,
Math.floor(availableWidth / estimatedItemWidth),
);
let requiredRows = Math.ceil(seriesCount / itemsPerRow);
if (requiredRows === 1 && seriesCount > 3) {
requiredRows = 2;
}
// Calculate heights
const idealHeight = requiredRows * lineHeight + padding;
// For single row, use minimal height
let minHeight;
if (requiredRows <= 1) {
minHeight = lineHeight + padding; // Single row
} else {
// Multiple rows: show 2 rows max, then scroll
minHeight = Math.min(2 * lineHeight + padding, idealHeight);
}
// For very small dimensions, allow the minHeight to be smaller to respect ratio constraints
if (dimensions.height < 200) {
minHeight = Math.min(minHeight, absoluteMaxHeight);
}
// Maximum height constraint - prioritize chart space
// Fix: Ensure we respect the ratio-based constraint for small dimensions
const rowBasedMaxHeight = maxRowsToShow * lineHeight + padding;
const maxHeight = Math.min(rowBasedMaxHeight, absoluteMaxHeight);
const calculatedHeight = Math.max(minHeight, Math.min(idealHeight, maxHeight));
const showScrollbar = idealHeight > calculatedHeight;
return {
minHeight,
maxHeight,
calculatedHeight,
showScrollbar,
requiredRows,
};
}
// CSS class constants
const LEGEND_SINGLE_LINE_CLASS = 'u-legend-single-line';
const LEGEND_MULTI_LINE_CLASS = 'u-legend-multi-line';
const LEGEND_RIGHT_ALIGNED_CLASS = 'u-legend-right-aligned';
/**
* Apply enhanced legend styling to a legend element
*/
export function applyEnhancedLegendStyling(
legend: HTMLElement,
config: EnhancedLegendConfig,
requiredRows: number,
legendPosition: LegendPosition = LegendPosition.BOTTOM,
): void {
const legendElement = legend;
legendElement.classList.add('u-legend-enhanced');
// Apply position-specific styling
if (legendPosition === LegendPosition.RIGHT) {
legendElement.classList.add('u-legend-right');
legendElement.classList.remove('u-legend-bottom');
// Set width for right-side legend
if (config.calculatedWidth) {
legendElement.style.width = `${config.calculatedWidth}px`;
legendElement.style.minWidth = `${config.minWidth}px`;
legendElement.style.maxWidth = `${config.maxWidth}px`;
}
// Height for right-side legend
legendElement.style.height = `${config.calculatedHeight}px`;
legendElement.style.minHeight = `${config.minHeight}px`;
legendElement.style.maxHeight = `${config.maxHeight}px`;
} else {
legendElement.classList.add('u-legend-bottom');
legendElement.classList.remove('u-legend-right');
// Height for bottom legend
legendElement.style.height = `${config.calculatedHeight}px`;
legendElement.style.minHeight = `${config.minHeight}px`;
legendElement.style.maxHeight = `${config.maxHeight}px`;
// Reset width for bottom legend
legendElement.style.width = '';
legendElement.style.minWidth = '';
legendElement.style.maxWidth = '';
}
// Apply alignment based on position and number of rows
if (legendPosition === LegendPosition.RIGHT) {
legendElement.classList.add(LEGEND_RIGHT_ALIGNED_CLASS);
legendElement.classList.remove(
LEGEND_SINGLE_LINE_CLASS,
LEGEND_MULTI_LINE_CLASS,
);
} else if (requiredRows === 1) {
legendElement.classList.add(LEGEND_SINGLE_LINE_CLASS);
legendElement.classList.remove(
LEGEND_MULTI_LINE_CLASS,
LEGEND_RIGHT_ALIGNED_CLASS,
);
} else {
legendElement.classList.add(LEGEND_MULTI_LINE_CLASS);
legendElement.classList.remove(
LEGEND_SINGLE_LINE_CLASS,
LEGEND_RIGHT_ALIGNED_CLASS,
);
}
// Add scrollbar indicator if needed
if (config.showScrollbar) {
legendElement.classList.add('u-legend-scrollable');
} else {
legendElement.classList.remove('u-legend-scrollable');
}
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
@@ -8,10 +9,16 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { FullViewProps } from 'container/GridCardLayout/GridCard/FullView/types';
import { saveLegendEntriesToLocalStorage } from 'container/GridCardLayout/GridCard/FullView/utils';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import {
applyEnhancedLegendStyling,
calculateEnhancedLegendConfig,
} from 'container/PanelWrapper/enhancedLegend';
import { Dimensions } from 'hooks/useDimensions';
import { convertValue } from 'lib/getConvertedValue';
import getLabelName from 'lib/getLabelName';
import { cloneDeep, isUndefined } from 'lodash-es';
import _noop from 'lodash-es/noop';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
@@ -60,6 +67,8 @@ export interface GetUPlotChartOptions {
customSeries?: (data: QueryData[]) => uPlot.Series[];
isLogScale?: boolean;
colorMapping?: Record<string, string>;
enhancedLegend?: boolean;
legendPosition?: LegendPosition;
enableZoom?: boolean;
}
@@ -169,6 +178,8 @@ export const getUPlotChartOptions = ({
customSeries,
isLogScale,
colorMapping,
enhancedLegend = true,
legendPosition = LegendPosition.BOTTOM,
enableZoom,
}: GetUPlotChartOptions): uPlot.Options => {
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
@@ -182,10 +193,42 @@ export const getUPlotChartOptions = ({
const bands = stackBarChart ? getBands(series) : null;
// Calculate dynamic legend configuration based on panel dimensions and series count
const seriesCount = (apiResponse?.data?.result || []).length;
const seriesLabels = enhancedLegend
? (apiResponse?.data?.result || []).map((item) =>
getLabelName(item.metric || {}, item.queryName || '', item.legend || ''),
)
: [];
const legendConfig = enhancedLegend
? calculateEnhancedLegendConfig(
dimensions,
seriesCount,
seriesLabels,
legendPosition,
)
: {
calculatedHeight: 30,
minHeight: 30,
maxHeight: 30,
itemsPerRow: 3,
showScrollbar: false,
};
// Calculate chart dimensions based on legend position
const chartWidth =
legendPosition === LegendPosition.RIGHT && legendConfig.calculatedWidth
? dimensions.width - legendConfig.calculatedWidth - 10
: dimensions.width;
const chartHeight =
legendPosition === LegendPosition.BOTTOM
? dimensions.height - legendConfig.calculatedHeight - 10
: dimensions.height;
return {
id,
width: dimensions.width,
height: dimensions.height - 30,
width: chartWidth,
height: chartHeight,
legend: {
show: true,
live: false,
@@ -353,13 +396,166 @@ export const getUPlotChartOptions = ({
],
ready: [
(self): void => {
// Add CSS classes to the uPlot container based on legend position
const uplotContainer = self.root;
if (uplotContainer) {
uplotContainer.classList.remove(
'u-plot-right-legend',
'u-plot-bottom-legend',
);
if (legendPosition === LegendPosition.RIGHT) {
uplotContainer.classList.add('u-plot-right-legend');
} else {
uplotContainer.classList.add('u-plot-bottom-legend');
}
}
const legend = self.root.querySelector('.u-legend');
if (legend) {
// Apply enhanced legend styling
if (enhancedLegend) {
applyEnhancedLegendStyling(
legend as HTMLElement,
legendConfig,
legendConfig.requiredRows,
legendPosition,
);
}
// Global cleanup function for all legend tooltips
const cleanupAllTooltips = (): void => {
const existingTooltips = document.querySelectorAll('.legend-tooltip');
existingTooltips.forEach((tooltip) => tooltip.remove());
};
// Add single global cleanup listener for this chart
const globalCleanupHandler = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
if (
!target.closest('.u-legend') &&
!target.classList.contains('legend-tooltip')
) {
cleanupAllTooltips();
}
};
document.addEventListener('mousemove', globalCleanupHandler);
// Store cleanup function for potential removal later
(self as any)._tooltipCleanup = (): void => {
cleanupAllTooltips();
document.removeEventListener('mousemove', globalCleanupHandler);
};
const seriesEls = legend.querySelectorAll('.u-series');
const seriesArray = Array.from(seriesEls);
seriesArray.forEach((seriesEl, index) => {
seriesEl.addEventListener('click', () => {
if (stackChart) {
// Add tooltip and proper text wrapping for legends
const thElement = seriesEl.querySelector('th');
if (thElement && seriesLabels[index]) {
// Store the original marker element before clearing
const markerElement = thElement.querySelector('.u-marker');
const markerClone = markerElement
? (markerElement.cloneNode(true) as HTMLElement)
: null;
// Get the current text content
const legendText = seriesLabels[index];
// Clear the th content and rebuild it
thElement.innerHTML = '';
// Add back the marker
if (markerClone) {
thElement.appendChild(markerClone);
}
// Create text wrapper
const textSpan = document.createElement('span');
textSpan.className = 'legend-text';
textSpan.textContent = legendText;
thElement.appendChild(textSpan);
// Setup tooltip functionality - check truncation on hover
let tooltipElement: HTMLElement | null = null;
let isHovering = false;
const showTooltip = (e: MouseEvent): void => {
// Check if text is actually truncated at the time of hover
const isTextTruncated = (): boolean => {
// For right-side legends, check if text overflows the container
if (legendPosition === LegendPosition.RIGHT) {
return textSpan.scrollWidth > textSpan.clientWidth;
}
// For bottom legends, check if text is longer than reasonable display length
return legendText.length > 20;
};
// Only show tooltip if text is actually truncated
if (!isTextTruncated()) {
return;
}
isHovering = true;
// Clean up any existing tooltips first
cleanupAllTooltips();
// Small delay to ensure cleanup is complete and DOM is ready
setTimeout(() => {
if (!isHovering) return; // Don't show if mouse already left
// Double-check no tooltip exists
if (document.querySelector('.legend-tooltip')) {
return;
}
// Create tooltip element
tooltipElement = document.createElement('div');
tooltipElement.className = 'legend-tooltip';
tooltipElement.textContent = legendText;
tooltipElement.style.cssText = `
position: fixed;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
z-index: 10000;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
border: 1px solid #374151;
`;
// Position tooltip near cursor
const rect = (e.target as HTMLElement).getBoundingClientRect();
tooltipElement.style.left = `${e.clientX + 10}px`;
tooltipElement.style.top = `${rect.top - 35}px`;
document.body.appendChild(tooltipElement);
}, 15);
};
const hideTooltip = (): void => {
isHovering = false;
// Simple cleanup with a reasonable delay
setTimeout(() => {
if (!isHovering && tooltipElement) {
tooltipElement.remove();
tooltipElement = null;
}
}, 200);
};
// Simple tooltip events
thElement.addEventListener('mouseenter', showTooltip);
thElement.addEventListener('mouseleave', hideTooltip);
// Add click handlers for marker and text separately
const currentMarker = thElement.querySelector('.u-marker');
const textElement = thElement.querySelector('.legend-text');
// Helper function to handle stack chart logic
const handleStackChart = (): void => {
setHiddenGraph((prev) => {
if (isUndefined(prev)) {
return { [index]: true };
@@ -369,30 +565,71 @@ export const getUPlotChartOptions = ({
}
return { [index]: true };
});
}
if (graphsVisibilityStates) {
setGraphsVisibilityStates?.((prev) => {
const newGraphVisibilityStates = [...prev];
if (
newGraphVisibilityStates[index + 1] &&
newGraphVisibilityStates.every((value, i) =>
i === index + 1 ? value : !value,
)
) {
newGraphVisibilityStates.fill(true);
} else {
newGraphVisibilityStates.fill(false);
newGraphVisibilityStates[index + 1] = true;
};
// Marker click handler - checkbox behavior (toggle individual series)
if (currentMarker) {
currentMarker.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent event bubbling to text handler
if (stackChart) {
handleStackChart();
}
if (graphsVisibilityStates) {
setGraphsVisibilityStates?.((prev) => {
const newGraphVisibilityStates = [...prev];
// Toggle the specific series visibility (checkbox behavior)
newGraphVisibilityStates[index + 1] = !newGraphVisibilityStates[
index + 1
];
saveLegendEntriesToLocalStorage({
options: self,
graphVisibilityState: newGraphVisibilityStates,
name: id || '',
});
return newGraphVisibilityStates;
});
}
saveLegendEntriesToLocalStorage({
options: self,
graphVisibilityState: newGraphVisibilityStates,
name: id || '',
});
return newGraphVisibilityStates;
});
}
});
// Text click handler - show only/show all behavior (existing behavior)
if (textElement) {
textElement.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent event bubbling
if (stackChart) {
handleStackChart();
}
if (graphsVisibilityStates) {
setGraphsVisibilityStates?.((prev) => {
const newGraphVisibilityStates = [...prev];
// Show only this series / show all behavior
if (
newGraphVisibilityStates[index + 1] &&
newGraphVisibilityStates.every((value, i) =>
i === index + 1 ? value : !value,
)
) {
// If only this series is visible, show all
newGraphVisibilityStates.fill(true);
} else {
// Otherwise, show only this series
newGraphVisibilityStates.fill(false);
newGraphVisibilityStates[index + 1] = true;
}
saveLegendEntriesToLocalStorage({
options: self,
graphVisibilityState: newGraphVisibilityStates,
name: id || '',
});
return newGraphVisibilityStates;
});
}
});
}
}
});
}
},
@@ -412,6 +649,7 @@ export const getUPlotChartOptions = ({
stackBarChart,
hiddenGraph,
isDarkMode,
colorMapping,
}),
axes: getAxes({ isDarkMode, yAxisUnit, panelType, isLogScale }),
};

View File

@@ -34,6 +34,7 @@ const getSeries = ({
panelType,
hiddenGraph,
isDarkMode,
colorMapping,
}: GetSeriesProps): uPlot.Options['series'] => {
const configurations: uPlot.Series[] = [
{ label: 'Timestamp', stroke: 'purple' },
@@ -52,10 +53,12 @@ const getSeries = ({
legend || '',
);
const color = generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
const color =
colorMapping?.[label] ||
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
);
const pointSize = seriesList[i].values.length > 1 ? 5 : 10;
const showPoints = !(seriesList[i].values.length > 1);
@@ -105,6 +108,7 @@ export type GetSeriesProps = {
hiddenGraph?: {
[key: string]: boolean;
};
colorMapping?: Record<string, string>;
};
export default getSeries;

View File

@@ -25,11 +25,44 @@ describe('getUPlotChartOptions', () => {
const options = getUPlotChartOptions(inputPropsTimeSeries);
expect(options.legend?.isolate).toBe(true);
expect(options.width).toBe(inputPropsTimeSeries.dimensions.width);
expect(options.height).toBe(inputPropsTimeSeries.dimensions.height - 30);
expect(options.axes?.length).toBe(2);
expect(options.series[1].label).toBe('A');
});
test('should return enhanced legend options when enabled', () => {
const options = getUPlotChartOptions({
...inputPropsTimeSeries,
enhancedLegend: true,
legendPosition: 'bottom' as any,
});
expect(options.legend?.isolate).toBe(true);
expect(options.legend?.show).toBe(true);
expect(options.hooks?.ready).toBeDefined();
expect(Array.isArray(options.hooks?.ready)).toBe(true);
});
test('should adjust chart dimensions for right legend position', () => {
const options = getUPlotChartOptions({
...inputPropsTimeSeries,
enhancedLegend: true,
legendPosition: 'right' as any,
});
expect(options.legend?.isolate).toBe(true);
expect(options.width).toBeLessThan(inputPropsTimeSeries.dimensions.width);
expect(options.height).toBe(inputPropsTimeSeries.dimensions.height);
});
test('should adjust chart dimensions for bottom legend position', () => {
const options = getUPlotChartOptions({
...inputPropsTimeSeries,
enhancedLegend: true,
legendPosition: 'bottom' as any,
});
expect(options.legend?.isolate).toBe(true);
expect(options.width).toBe(inputPropsTimeSeries.dimensions.width);
expect(options.height).toBeLessThan(inputPropsTimeSeries.dimensions.height);
});
test('Should return line chart as drawStyle for time series', () => {
const options = getUPlotChartOptions(inputPropsTimeSeries);
// @ts-ignore

View File

@@ -17,12 +17,12 @@ body {
}
.u-legend {
max-height: 30px; // slicing the height of the widget Header height ;
max-height: 30px; // Default height for backward compatibility
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.3rem;
width: 0.5rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
@@ -53,6 +53,313 @@ body {
text-decoration-thickness: 3px;
}
}
// Enhanced legend styles
&.u-legend-enhanced {
max-height: none; // Remove default max-height restriction
padding: 6px 4px; // Back to original padding
// Thin and neat scrollbar for enhanced legend
&::-webkit-scrollbar {
width: 0.25rem;
height: 0.25rem;
}
&::-webkit-scrollbar-thumb {
background: rgba(136, 136, 136, 0.4);
border-radius: 0.125rem;
&:hover {
background: rgba(136, 136, 136, 0.7);
}
}
&::-webkit-scrollbar-track {
background: transparent;
}
// Enhanced table layout for better responsiveness
table {
width: 100%;
table-layout: fixed;
}
tbody {
display: flex;
flex-wrap: wrap;
gap: 1px 2px;
align-items: center;
justify-content: flex-start;
width: 100%;
}
// Center alignment for single-line legends
&.u-legend-single-line tbody {
justify-content: center;
}
&.u-legend-right-aligned {
tbody {
align-items: flex-start !important;
justify-content: flex-start !important;
}
tr.u-series {
justify-content: flex-start !important;
th {
justify-content: flex-start !important;
text-align: left !important;
.legend-text {
text-align: left !important;
}
}
}
}
// Right-side legend specific styles
&.u-legend-right {
tbody {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
justify-content: flex-start;
gap: 2px;
}
tr.u-series {
width: 100%;
th {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
justify-content: flex-start;
cursor: pointer;
position: relative;
min-width: 0;
width: 100%;
.u-marker {
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
// Text container for proper ellipsis
.legend-text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
min-width: 0;
flex: 1;
padding-bottom: 2px;
}
// Tooltip styling
&[title] {
cursor: pointer;
}
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}
&.u-off {
opacity: 0.5;
text-decoration: line-through;
text-decoration-thickness: 1px;
th {
&:hover {
opacity: 0.7;
}
.u-marker {
opacity: 0.3;
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 2px;
background: #ff4444;
transform: translate(-50%, -50%) rotate(45deg);
border-radius: 1px;
}
&:hover {
opacity: 0.6;
}
}
}
}
// Focus styles for keyboard navigation
&:focus {
outline: 1px solid rgba(66, 165, 245, 0.8);
outline-offset: 1px;
}
}
}
// Bottom legend specific styles
&.u-legend-bottom {
tbody {
flex-direction: row;
flex-wrap: wrap;
}
}
&.u-legend-bottom tr.u-series {
display: flex;
flex: 0 0 auto;
min-width: fit-content;
max-width: 200px; // Limit width to enable truncation
th {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
padding: 6px 10px;
cursor: pointer;
white-space: nowrap;
-webkit-font-smoothing: antialiased;
border-radius: 2px;
min-width: 0; // Allow shrinking
max-width: 100%;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
.u-marker {
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-text {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
min-width: 0;
flex: 1;
padding-bottom: 2px;
}
// Tooltip styling
&[title] {
cursor: pointer;
}
}
&.u-off {
opacity: 0.5;
text-decoration: line-through;
text-decoration-thickness: 1px;
th {
&:hover {
opacity: 0.7;
}
.u-marker {
opacity: 0.3;
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 2px;
background: #ff4444;
transform: translate(-50%, -50%) rotate(45deg);
border-radius: 1px;
}
&:hover {
opacity: 0.6;
}
}
}
}
// Focus styles for keyboard navigation
&:focus {
outline: 1px solid rgba(66, 165, 245, 0.8);
outline-offset: 1px;
}
}
}
}
// uPlot container adjustments for right-side legend
.uplot {
&.u-plot-right-legend {
display: flex;
flex-direction: row;
.u-over {
flex: 1;
}
.u-legend {
flex-shrink: 0;
margin-top: 0;
margin-bottom: 0;
}
}
&.u-plot-bottom-legend {
display: flex;
flex-direction: column;
.u-legend {
margin-top: 10px;
margin-left: 0;
margin-right: 0;
}
}
}
/* Style the selected background */
@@ -250,6 +557,94 @@ body {
}
}
}
// Enhanced legend light mode styles
.u-legend-enhanced {
// Light mode scrollbar styling
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
&:hover {
background: rgba(0, 0, 0, 0.4);
}
}
&.u-legend-bottom tr.u-series {
th {
&:hover {
background: rgba(0, 0, 0, 0.05);
}
}
&.u-off {
opacity: 0.5;
text-decoration: line-through;
text-decoration-thickness: 1px;
th {
&:hover {
background: rgba(0, 0, 0, 0.08);
opacity: 0.7;
}
.u-marker {
opacity: 0.3;
&::after {
background: #cc3333;
}
&:hover {
opacity: 0.6;
}
}
}
}
// Light mode focus styles
&:focus {
outline: 1px solid rgba(25, 118, 210, 0.8);
}
}
&.u-legend-right tr.u-series {
th {
&:hover {
background: rgba(0, 0, 0, 0.05);
}
}
&.u-off {
opacity: 0.5;
text-decoration: line-through;
text-decoration-thickness: 1px;
th {
&:hover {
background: rgba(0, 0, 0, 0.08);
opacity: 0.7;
}
.u-marker {
opacity: 0.3;
&::after {
background: #cc3333;
}
&:hover {
opacity: 0.6;
}
}
}
}
// Light mode focus styles
&:focus {
outline: 1px solid rgba(25, 118, 210, 0.8);
}
}
}
}
.ant-notification-notice-message {
@@ -320,3 +715,30 @@ notifications - 2050
.animate-spin {
animation: spin 1s linear infinite;
}
// Custom legend tooltip for immediate display
.legend-tooltip {
position: fixed;
background: var(--bg-slate-400);
color: var(--text-vanilla-100);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: 'Geist Mono';
font-weight: 500;
z-index: 10000;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
border: 1px solid #374151;
-webkit-font-smoothing: antialiased;
letter-spacing: 0.025em;
}
// Light mode styling for legend tooltip
.lightMode .legend-tooltip {
background: #ffffff;
color: #1f2937;
border: 1px solid #d1d5db;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

View File

@@ -17,6 +17,11 @@ export type TVariableQueryType = typeof VariableQueryTypeArr[number];
export const VariableSortTypeArr = ['DISABLED', 'ASC', 'DESC'] as const;
export type TSortVariableValuesType = typeof VariableSortTypeArr[number];
export enum LegendPosition {
BOTTOM = 'bottom',
RIGHT = 'right',
}
export interface IDashboardVariable {
id: string;
order?: any;
@@ -111,6 +116,8 @@ export interface IBaseWidget {
selectedTracesFields: BaseAutocompleteData[] | null;
isLogScale?: boolean;
columnWidths?: Record<string, number>;
legendPosition?: LegendPosition;
customLegendColors?: Record<string, string>;
}
export interface Widgets extends IBaseWidget {
query: Query;