mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-14 19:26:01 +08:00
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
This commit is contained in:
parent
d1d7da6c9b
commit
aaeffae1bd
@ -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>
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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({
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{allowCreateAlerts && (
|
||||
@ -495,6 +527,8 @@ interface RightContainerProps {
|
||||
setSoftMax: Dispatch<SetStateAction<number | null>>;
|
||||
isLogScale: boolean;
|
||||
setIsLogScale: Dispatch<SetStateAction<boolean>>;
|
||||
legendPosition: LegendPosition;
|
||||
setLegendPosition: Dispatch<SetStateAction<LegendPosition>>;
|
||||
}
|
||||
|
||||
RightContainer.defaultProps = {
|
||||
|
@ -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<boolean>(
|
||||
selectedWidget?.isLogScale || false,
|
||||
);
|
||||
const [legendPosition, setLegendPosition] = useState<LegendPosition>(
|
||||
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}
|
||||
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
246
frontend/src/container/PanelWrapper/enhancedLegend.ts
Normal file
246
frontend/src/container/PanelWrapper/enhancedLegend.ts
Normal 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');
|
||||
}
|
||||
}
|
@ -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 }),
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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<string, number>;
|
||||
legendPosition?: LegendPosition;
|
||||
}
|
||||
export interface Widgets extends IBaseWidget {
|
||||
query: Query;
|
||||
|
Loading…
x
Reference in New Issue
Block a user