From aaeffae1bd53a2f5f8b805dd7aef993b5cef10f2 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Tue, 27 May 2025 13:50:40 +0530 Subject: [PATCH] feat: added enhancements to legends in panel (#8035) * feat: added enhancements to legends in panel * feat: added option for right side legends * feat: created the legend marker as checkboxes * feat: removed histogram and pie from enhanced legends * feat: row num adjustment * feat: added graph visibilty in panel edit mode also * feat: allignment and fixes * feat: added test cases --- .../WidgetGraph/WidgetGraphs.tsx | 30 + .../RightContainer/RightContainer.styles.scss | 8 + .../NewWidget/RightContainer/constants.ts | 14 + .../NewWidget/RightContainer/index.tsx | 36 +- frontend/src/container/NewWidget/index.tsx | 16 +- .../PanelWrapper/UplotPanelWrapper.tsx | 3 + .../__tests__/enhancedLegend.test.ts | 521 ++++++++++++++++++ .../container/PanelWrapper/enhancedLegend.ts | 246 +++++++++ .../src/lib/uPlotLib/getUplotChartOptions.ts | 288 +++++++++- .../utils/tests/getUplotChartOptions.test.ts | 35 +- frontend/src/styles.scss | 426 +++++++++++++- frontend/src/types/api/dashboard/getAll.ts | 6 + 12 files changed, 1599 insertions(+), 30 deletions(-) create mode 100644 frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts create mode 100644 frontend/src/container/PanelWrapper/enhancedLegend.ts diff --git a/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx b/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx index ab4e67120f..f142bd7043 100644 --- a/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx @@ -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(null); + const lineChartRef = useRef(); const dispatch = useDispatch(); const urlQuery = useUrlQuery(); const location = useLocation(); const { safeNavigate } = useSafeNavigate(); + // Add legend state management similar to dashboard components + const [graphVisibility, setGraphVisibility] = useState( + 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} /> ); diff --git a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss index 8855eae498..a32fcb8850 100644 --- a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss +++ b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss @@ -166,6 +166,14 @@ gap: 8px; } + .legend-position { + margin-top: 16px; + display: flex; + justify-content: space-between; + flex-direction: column; + gap: 8px; + } + .panel-time-text { margin-top: 16px; color: var(--bg-vanilla-400); diff --git a/frontend/src/container/NewWidget/RightContainer/constants.ts b/frontend/src/container/NewWidget/RightContainer/constants.ts index cec2f8a600..3735b684a5 100644 --- a/frontend/src/container/NewWidget/RightContainer/constants.ts +++ b/frontend/src/container/NewWidget/RightContainer/constants.ts @@ -150,3 +150,17 @@ 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; diff --git a/frontend/src/container/NewWidget/RightContainer/index.tsx b/frontend/src/container/NewWidget/RightContainer/index.tsx index 86ada0d4ba..ac7f0fede5 100644 --- a/frontend/src/container/NewWidget/RightContainer/index.tsx +++ b/frontend/src/container/NewWidget/RightContainer/index.tsx @@ -30,7 +30,11 @@ import { useRef, useState, } from 'react'; -import { ColumnUnit, Widgets } from 'types/api/dashboard/getAll'; +import { + ColumnUnit, + LegendPosition, + Widgets, +} from 'types/api/dashboard/getAll'; import { DataSource } from 'types/common/queryBuilder'; import { popupContainer } from 'utils/selectPopupContainer'; @@ -40,6 +44,7 @@ import { panelTypeVsColumnUnitPreferences, panelTypeVsCreateAlert, panelTypeVsFillSpan, + panelTypeVsLegendPosition, panelTypeVsLogScale, panelTypeVsPanelTimePreferences, panelTypeVsSoftMinMax, @@ -98,6 +103,8 @@ function RightContainer({ setColumnUnits, isLogScale, setIsLogScale, + legendPosition, + setLegendPosition, }: RightContainerProps): JSX.Element { const { selectedDashboard } = useDashboard(); const [inputValue, setInputValue] = useState(title); @@ -128,6 +135,7 @@ function RightContainer({ panelTypeVsStackingChartPreferences[selectedGraph]; const allowPanelTimePreference = panelTypeVsPanelTimePreferences[selectedGraph]; + const allowLegendPosition = panelTypeVsLegendPosition[selectedGraph]; const allowPanelColumnPreference = panelTypeVsColumnUnitPreferences[selectedGraph]; @@ -430,6 +438,30 @@ function RightContainer({ )} + + {allowLegendPosition && ( +
+ Legend Position + +
+ )} {allowCreateAlerts && ( @@ -495,6 +527,8 @@ interface RightContainerProps { setSoftMax: Dispatch>; isLogScale: boolean; setIsLogScale: Dispatch>; + legendPosition: LegendPosition; + setLegendPosition: Dispatch>; } RightContainer.defaultProps = { diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index fc337081d0..06d73f5751 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -37,7 +37,12 @@ import { useTranslation } from 'react-i18next'; 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 { + ColumnUnit, + Dashboard, + LegendPosition, + Widgets, +} from 'types/api/dashboard/getAll'; import { IField } from 'types/api/logs/fields'; import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; @@ -183,6 +188,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { const [isLogScale, setIsLogScale] = useState( selectedWidget?.isLogScale || false, ); + const [legendPosition, setLegendPosition] = useState( + selectedWidget?.legendPosition || LegendPosition.BOTTOM, + ); const [saveModal, setSaveModal] = useState(false); const [discardModal, setDiscardModal] = useState(false); @@ -248,6 +256,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { selectedLogFields, selectedTracesFields, isLogScale, + legendPosition, columnWidths: columnWidths?.[selectedWidget?.id], }; }); @@ -272,6 +281,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { combineHistogram, stackedBarChart, isLogScale, + legendPosition, columnWidths, ]); @@ -471,6 +481,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false, selectedLogFields: selectedWidget?.selectedLogFields || [], selectedTracesFields: selectedWidget?.selectedTracesFields || [], + legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM, }, ] : [ @@ -498,6 +509,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false, selectedLogFields: selectedWidget?.selectedLogFields || [], selectedTracesFields: selectedWidget?.selectedTracesFields || [], + legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM, }, ...afterWidgets, ], @@ -752,6 +764,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { setIsFillSpans={setIsFillSpans} isLogScale={isLogScale} setIsLogScale={setIsLogScale} + legendPosition={legendPosition} + setLegendPosition={setLegendPosition} softMin={softMin} setSoftMin={setSoftMin} softMax={softMax} diff --git a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx index 77b29c7798..67099b48bd 100644 --- a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx +++ b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx @@ -138,6 +138,8 @@ function UplotPanelWrapper({ timezone: timezone.value, customSeries, isLogScale: widget?.isLogScale, + enhancedLegend: true, // Enable enhanced legend + legendPosition: widget?.legendPosition, }), [ widget?.id, @@ -163,6 +165,7 @@ function UplotPanelWrapper({ timezone.value, customSeries, widget?.isLogScale, + widget?.legendPosition, ], ); diff --git a/frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts b/frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts new file mode 100644 index 0000000000..036a540397 --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/container/PanelWrapper/enhancedLegend.ts b/frontend/src/container/PanelWrapper/enhancedLegend.ts new file mode 100644 index 0000000000..948521593c --- /dev/null +++ b/frontend/src/container/PanelWrapper/enhancedLegend.ts @@ -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'); + } +} diff --git a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts index 58b1dc00ce..f9f8470328 100644 --- a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts +++ b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts @@ -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; + 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 }), }; diff --git a/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts b/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts index a955d787ac..cf9ca03221 100644 --- a/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts +++ b/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts @@ -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 diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 0f574942cf..3dd190128c 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -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); +} diff --git a/frontend/src/types/api/dashboard/getAll.ts b/frontend/src/types/api/dashboard/getAll.ts index 65e26d0cbb..d9bfb1af43 100644 --- a/frontend/src/types/api/dashboard/getAll.ts +++ b/frontend/src/types/api/dashboard/getAll.ts @@ -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,7 @@ export interface IBaseWidget { selectedTracesFields: BaseAutocompleteData[] | null; isLogScale?: boolean; columnWidths?: Record; + legendPosition?: LegendPosition; } export interface Widgets extends IBaseWidget { query: Query;