mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 14:00:42 +01:00
Compare commits
7 Commits
nv/text-pa
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbdb55b434 | ||
|
|
d051430206 | ||
|
|
953e69baec | ||
|
|
5f5e3dea2a | ||
|
|
336b9e8d8b | ||
|
|
f5083f220a | ||
|
|
67293da003 |
270
frontend/src/lib/uPlotV2/components/Legend/Legend.module.scss
Normal file
270
frontend/src/lib/uPlotV2/components/Legend/Legend.module.scss
Normal file
@@ -0,0 +1,270 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Let the flex children shrink below their content height so the series list
|
||||
// scrolls within the capped legend height instead of overflowing the wrapper
|
||||
// (the default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-5, 10px);
|
||||
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
|
||||
flex-shrink: 0;
|
||||
|
||||
> * {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
// Wraps rather than losing the count at its end.
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.searchContainer {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-5, 10px);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
/* Series list */
|
||||
|
||||
.scroller {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// toolbar instead of growing to fit every row.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-right: var(--spacing-2);
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.gridItem {
|
||||
// Or the item keeps its content width and the label never ellipsizes.
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.gridList {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
// min() keeps the column inside a narrow panel, where a wider one would push
|
||||
// the row's actions out of the clipped area.
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
|
||||
);
|
||||
gap: var(--spacing-1) var(--spacing-4);
|
||||
}
|
||||
|
||||
.container.isRight .gridList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: var(--spacing-16) 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Row */
|
||||
|
||||
.row {
|
||||
// Width of the revealed actions, given up by the label on hover only.
|
||||
--legend-actions-reserve: 78px;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 28px;
|
||||
padding: 0 var(--spacing-3) 0 var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 160ms linear;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.isFocused {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.marker {
|
||||
// Reads as a checkbox without being one: filled when shown, hollow when
|
||||
// hidden, deliberately not a check glyph.
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
// Above the actions, so a narrow row's chip never covers the series colour.
|
||||
z-index: 4;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
border-width: 1.5px;
|
||||
border-style: solid;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease,
|
||||
background-color 160ms linear,
|
||||
opacity 160ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 2px
|
||||
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Series names run long and have no spaces to break on, so they need both a
|
||||
// cap and a break rule or the tooltip becomes one panel-wide line.
|
||||
.rowTooltip {
|
||||
max-width: 420px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l2-foreground);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.isHidden .marker {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.isHidden .label {
|
||||
color: var(--l3-foreground);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* Row actions */
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-3);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding-left: var(--spacing-5, 10px);
|
||||
// Sits on the row's hover background and masks the label's tail behind it.
|
||||
background: var(--l3-background);
|
||||
box-shadow: -8px 0 8px var(--l3-background);
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
}
|
||||
|
||||
// :focus-visible, not :focus-within — the latter also matches the click that
|
||||
// just toggled the series, leaving the actions stuck open.
|
||||
.row:hover .actions,
|
||||
.row:focus-visible .actions,
|
||||
.row:has(:focus-visible) .actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// The cap spares rows sized to their actions, not their label.
|
||||
.row:hover .label,
|
||||
.row:focus-visible .label,
|
||||
.row:has(:focus-visible) .label {
|
||||
padding-right: min(var(--legend-actions-reserve), 50%);
|
||||
}
|
||||
|
||||
.actionTrigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
--button-height: 20px;
|
||||
--button-width: 20px;
|
||||
--button-padding: 0;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actionButton.scopeButton {
|
||||
--button-width: auto;
|
||||
--button-padding: 0 var(--spacing-4);
|
||||
--button-font-size: var(--font-size-xs);
|
||||
--button-border-radius: calc(var(--radius) * 4);
|
||||
--button-base-border-width: 1px;
|
||||
// --l2-border is the actions bar's own background: one step up reads.
|
||||
--button-base-border-color: var(--l3-border);
|
||||
|
||||
border-style: solid;
|
||||
}
|
||||
.actionButton.scopeButton:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.legend-search-container {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
|
||||
.legend-search-input {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
// Allow the flex children to shrink below their content height so the
|
||||
// virtualized grid scrolls within the capped legend height instead of
|
||||
// overflowing the wrapper (default min-height:auto would block the shrink).
|
||||
min-height: 0;
|
||||
|
||||
&:has(.legend-item-focused) .legend-item {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:has(.legend-item-focused) .legend-item.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.legend-virtuoso-container {
|
||||
// flex:1 + min-height:0 pins the scroller to the space left after the
|
||||
// search box (RIGHT legend) and lets it scroll instead of growing to fit
|
||||
// every row — without this the grid overflows a BOTTOM legend's fixed height.
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.virtuoso-grid-list {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.virtuoso-grid-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-right {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&.legend-virtuoso-container-single-row {
|
||||
.virtuoso-grid-list {
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(var(--legend-average-width, 240px), max-content)
|
||||
);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
|
||||
&.legend-single-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.legend-row-right {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
&.legend-row-bottom {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
// Include padding within the width so a full-width row (legend-item-right) fits its
|
||||
// column instead of overflowing by the 16px horizontal padding — there is no global
|
||||
// border-box reset, so the default content-box would make it overflow.
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&.legend-item-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.legend-item-off {
|
||||
opacity: 0.3;
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
&.legend-item-focused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-item-label-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-marker {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
min-width: 11px;
|
||||
min-height: 11px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 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-label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.legend-copy-button {
|
||||
// Always laid out (space reserved) but transparent, so revealing it on
|
||||
// hover fades the icon in without reflowing the row / shifting the label.
|
||||
// Shrink the shared icon Button (defaults to a 2rem square) to the
|
||||
// compact legend row via its size tokens.
|
||||
--button-height: auto;
|
||||
--button-width: auto;
|
||||
--button-padding: 2px;
|
||||
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
.legend-copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,136 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational legend. Renders the supplied `items` (markers + labels, an
|
||||
* optional copy button, and a search box for the RIGHT position) and delegates
|
||||
* all interaction to the container handlers. Source-agnostic — the uPlot
|
||||
* charts feed it via UPlotLegend; Pie feeds it directly.
|
||||
* Presentational legend, source-agnostic: the uPlot charts feed it via
|
||||
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
showCopy = true,
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
const isSingleRow = useMemo(() => {
|
||||
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
|
||||
return false;
|
||||
}
|
||||
const totalLegendWidth = items.length * (averageLegendWidth + 16);
|
||||
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
|
||||
return totalRows <= 1;
|
||||
}, [averageLegendWidth, items.length, position, containerWidth]);
|
||||
|
||||
const visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const query = legendSearchQuery.trim().toLowerCase();
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
return (
|
||||
<div
|
||||
key={item.seriesIndex}
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
|
||||
<div className="legend-item-label-trigger">
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: markerColor }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
// A bottom legend gets two rows; spending one on chrome costs more chart than
|
||||
// the readout is worth.
|
||||
const showToolbar = isRightPosition && items.length > 0;
|
||||
const showFilter = showToolbar;
|
||||
|
||||
const effectiveQuery = showFilter ? filterQuery : '';
|
||||
|
||||
const visibleLegendItems = useMemo(
|
||||
() => filterLegendItems(items, effectiveQuery),
|
||||
[items, effectiveQuery],
|
||||
);
|
||||
|
||||
const isEmptyState =
|
||||
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
|
||||
|
||||
const isAllShown = visibleCount === items.length;
|
||||
|
||||
/** Everything showing -> isolate, since there is nothing to exclude yet. */
|
||||
const handleRowClick = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
if (isAllShown) {
|
||||
onShowOnlySeries(seriesIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onToggleSeries(seriesIndex);
|
||||
},
|
||||
[isAllShown, onShowOnlySeries, onToggleSeries],
|
||||
);
|
||||
|
||||
// A row that unmounts under the pointer never fires its own mouseleave.
|
||||
const handleMouseLeave = useCallback(
|
||||
(): void => onHoverSeries(null),
|
||||
[onHoverSeries],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => (
|
||||
<LegendRow
|
||||
key={item.seriesIndex}
|
||||
item={item}
|
||||
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
|
||||
isAllShown={isAllShown}
|
||||
isFocused={focusedSeriesIndex === item.seriesIndex}
|
||||
showCopy={showCopy}
|
||||
onRowClick={handleRowClick}
|
||||
onToggleVisibility={onToggleSeries}
|
||||
onShowOnly={onShowOnlySeries}
|
||||
onShowAll={onShowAllSeries}
|
||||
onHover={onHoverSeries}
|
||||
/>
|
||||
),
|
||||
[
|
||||
soleShownSeriesIndex,
|
||||
isAllShown,
|
||||
focusedSeriesIndex,
|
||||
showCopy,
|
||||
handleRowClick,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="legend-container"
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
className={cx(styles.container, {
|
||||
[styles.isRight]: isRightPosition,
|
||||
})}
|
||||
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="legend-container"
|
||||
>
|
||||
{searchEnabled && (
|
||||
<div className="legend-search-container">
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="Search..."
|
||||
value={legendSearchQuery}
|
||||
onChange={(e): void => setLegendSearchQuery(e.target.value)}
|
||||
data-testid="legend-search-input"
|
||||
className="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
{showToolbar && (
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className="legend-empty-state">
|
||||
No series found matching "{legendSearchQuery}"
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
|
||||
180
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
180
frontend/src/lib/uPlotV2/components/Legend/LegendRow.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
export interface LegendRowProps {
|
||||
item: LegendItem;
|
||||
/** The only series currently shown, so hiding it is refused. */
|
||||
isSoleShown: boolean;
|
||||
/** Nothing is hidden, so the row's action can only narrow the selection. */
|
||||
isAllShown: boolean;
|
||||
isFocused: boolean;
|
||||
showCopy: boolean;
|
||||
/** Row click, whose meaning depends on how many series are shown. */
|
||||
onRowClick: (seriesIndex: number) => void;
|
||||
/** Marker click: hide or show just this series. */
|
||||
onToggleVisibility: (seriesIndex: number) => void;
|
||||
onShowOnly: (seriesIndex: number) => void;
|
||||
onShowAll: () => void;
|
||||
onHover: (seriesIndex: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One legend row. The marker is its own target for excluding a single series —
|
||||
* the one thing the row click can't do while everything is showing. The actions
|
||||
* overlay the label's tail rather than taking layout width, and their reveal is
|
||||
* pure CSS.
|
||||
*/
|
||||
function LegendRow({
|
||||
item,
|
||||
isSoleShown,
|
||||
isAllShown,
|
||||
isFocused,
|
||||
showCopy,
|
||||
onRowClick,
|
||||
onToggleVisibility,
|
||||
onShowOnly,
|
||||
onShowAll,
|
||||
onHover,
|
||||
}: LegendRowProps): JSX.Element {
|
||||
const { seriesIndex, show } = item;
|
||||
const label = item.label ?? '';
|
||||
const isShowAllAction = show && !isAllShown;
|
||||
const scopeActionLabel = isShowAllAction
|
||||
? 'Show all series'
|
||||
: 'Show only current series';
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
|
||||
// a usable CSS colour for the marker.
|
||||
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(): void => onRowClick(seriesIndex),
|
||||
[onRowClick, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
onToggleVisibility(seriesIndex);
|
||||
},
|
||||
[onToggleVisibility, seriesIndex],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
// Let the row actions handle their own keys.
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onRowClick(seriesIndex);
|
||||
}
|
||||
},
|
||||
[onRowClick, seriesIndex],
|
||||
);
|
||||
|
||||
const handleScopeClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
event.stopPropagation();
|
||||
if (isShowAllAction) {
|
||||
onShowAll();
|
||||
return;
|
||||
}
|
||||
onShowOnly(seriesIndex);
|
||||
},
|
||||
[isShowAllAction, onShowAll, onShowOnly, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(): void => onHover(seriesIndex),
|
||||
[onHover, seriesIndex],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => onHover(null), [onHover]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, {
|
||||
[styles.isHidden]: !show,
|
||||
[styles.isFocused]: isFocused,
|
||||
})}
|
||||
data-legend-item-id={seriesIndex}
|
||||
data-testid={`legend-item-${seriesIndex}`}
|
||||
role="switch"
|
||||
tabIndex={0}
|
||||
aria-checked={show}
|
||||
aria-label={label}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.marker}
|
||||
style={{
|
||||
borderColor: seriesColor,
|
||||
backgroundColor: show ? seriesColor : 'transparent',
|
||||
}}
|
||||
onClick={handleMarkerClick}
|
||||
disabled={isSoleShown}
|
||||
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={`legend-marker-${seriesIndex}`}
|
||||
/>
|
||||
<TooltipSimple
|
||||
title={label}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
</TooltipSimple>
|
||||
<div className={styles.actions}>
|
||||
<TooltipSimple
|
||||
title={scopeActionLabel}
|
||||
arrow
|
||||
side="top"
|
||||
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
|
||||
disableHoverableContent
|
||||
tooltipContentProps={{ className: styles.rowTooltip }}
|
||||
>
|
||||
{/* Radix's asChild merge strips the button's own data-testid. */}
|
||||
<span className={styles.actionTrigger}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={cx(styles.actionButton, styles.scopeButton)}
|
||||
onClick={handleScopeClick}
|
||||
aria-label={scopeActionLabel}
|
||||
testId={`legend-scope-${seriesIndex}`}
|
||||
>
|
||||
{isShowAllAction ? 'All' : 'Only'}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<CopyButton
|
||||
value={label}
|
||||
size={13}
|
||||
className={styles.actionButton}
|
||||
ariaLabel={`Copy ${label}`}
|
||||
testId={`legend-copy-${seriesIndex}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LegendRow);
|
||||
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
56
frontend/src/lib/uPlotV2/components/Legend/LegendToolbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
export interface LegendToolbarProps {
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
/** Search is intrinsic to the right-positioned legend. */
|
||||
showFilter: boolean;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
}
|
||||
|
||||
/** Legend chrome: the series search box and the "Showing N of M" readout. */
|
||||
export default function LegendToolbar({
|
||||
visibleCount,
|
||||
totalCount,
|
||||
showFilter,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
onFilterQueryChange(event.target.value),
|
||||
[onFilterQueryChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilter && (
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search size={12} className={styles.searchIcon} />}
|
||||
placeholder="Search..."
|
||||
value={filterQuery}
|
||||
onChange={handleFilterChange}
|
||||
className={styles.searchInput}
|
||||
data-testid="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.status}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import Legend from './Legend';
|
||||
|
||||
/**
|
||||
* uPlot legend controller. Derives the legend items + focus/visibility state
|
||||
* from the chart config (useLegendsSync) and the toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* from the chart config (useLegendsSync) and the series interactions from the
|
||||
* plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
@@ -17,13 +17,9 @@ export default function UPlotLegend({
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const { onToggleSeries, onShowOnlySeries, onShowAllSeries, onHoverSeries } =
|
||||
useLegendActions();
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
@@ -33,9 +29,10 @@ export default function UPlotLegend({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onToggleSeries={onToggleSeries}
|
||||
onShowOnlySeries={onShowOnlySeries}
|
||||
onShowAllSeries={onShowAllSeries}
|
||||
onHoverSeries={onHoverSeries}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { filterLegendItems, getShownSeriesState } from '../utils';
|
||||
|
||||
const items = (shown: boolean[]): LegendItem[] =>
|
||||
shown.map((show, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label: `series-${index}`,
|
||||
color: '#000',
|
||||
show,
|
||||
}));
|
||||
|
||||
describe('getShownSeriesState', () => {
|
||||
it('counts the shown series', () => {
|
||||
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
|
||||
visibleCount: 2,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('names the series when exactly one is shown', () => {
|
||||
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
soleShownSeriesIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports nothing shown', () => {
|
||||
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLegendItems', () => {
|
||||
it('matches case-insensitively on the label', () => {
|
||||
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
|
||||
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
|
||||
});
|
||||
|
||||
it('returns every item for a blank query', () => {
|
||||
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
20
frontend/src/lib/uPlotV2/components/Legend/constants.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Widest a single legend item is allowed to get when sizing the legend grid. */
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* Enough for a row to contain its own hover actions, which a short label would
|
||||
* otherwise size a column too narrow for. Little room for the label is intended.
|
||||
*/
|
||||
export const MIN_LEGEND_ITEM_WIDTH = 110;
|
||||
|
||||
/** Marker + row padding, on top of the estimated label width. */
|
||||
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
|
||||
|
||||
/** Must match `.row`'s height and the grid's row gap, or the reserved
|
||||
* rectangle clips a row. */
|
||||
export const LEGEND_ROW_HEIGHT = 28;
|
||||
export const LEGEND_ROW_GAP = 2;
|
||||
export const LEGEND_MAX_BOTTOM_ROWS = 2;
|
||||
|
||||
/** Hover delay before a row's full-name tooltip opens. */
|
||||
export const LEGEND_TOOLTIP_DELAY_MS = 500;
|
||||
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
34
frontend/src/lib/uPlotV2/components/Legend/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
export interface ShownSeriesState {
|
||||
visibleCount: number;
|
||||
/** The series index when exactly one series is shown, else null. */
|
||||
soleShownSeriesIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven by what is actually shown, never a remembered isolation: hiding series
|
||||
* one at a time down to a single one is the same state as "Only".
|
||||
*/
|
||||
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
|
||||
const shown = items.filter((item) => item.show);
|
||||
|
||||
return {
|
||||
visibleCount: shown.length,
|
||||
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterLegendItems(
|
||||
items: LegendItem[],
|
||||
query: string,
|
||||
): LegendItem[] {
|
||||
const normalisedQuery = query.trim().toLowerCase();
|
||||
if (!normalisedQuery) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item) =>
|
||||
item.label?.toLowerCase().includes(normalisedQuery),
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: 50%;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
border-width: 1.5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
@@ -30,11 +31,23 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// The legend's mono type; the container's Inter stays for the header.
|
||||
.uplotTooltipItemLabel,
|
||||
.uplotTooltipItemValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.uplotTooltipItemLabel {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.uplotTooltipItemValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.uplotTooltipItemContentSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function TooltipItem({
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color }}
|
||||
style={{ borderColor: item.color, backgroundColor: item.color }}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={markerTestId}
|
||||
/>
|
||||
@@ -39,7 +39,7 @@ export default function TooltipItem({
|
||||
className={Styles.uplotTooltipItemContentSeparator}
|
||||
style={{ borderColor: item.color }}
|
||||
/>
|
||||
<span>{item.tooltipValue}</span>
|
||||
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,16 +61,16 @@ describe('UPlotLegend', () => {
|
||||
},
|
||||
};
|
||||
|
||||
let onLegendClick: jest.Mock;
|
||||
let onLegendMouseMove: jest.Mock;
|
||||
let onLegendMouseLeave: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onToggleSeries: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onHoverSeries: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onLegendClick = jest.fn();
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onToggleSeries = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onHoverSeries = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
@@ -79,10 +79,10 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue({
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onFocusSeries,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,28 +102,39 @@ describe('UPlotLegend', () => {
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders search input when legend position is RIGHT', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render search input when legend position is BOTTOM (default)', () => {
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
|
||||
// The row interactions are the same in both placements.
|
||||
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the correct border color', () => {
|
||||
it('renders the marker with the series colour, filled only when shown', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendMarker = document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(legendMarker).toHaveStyle({
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
'background-color': '#ff0000',
|
||||
});
|
||||
// Hidden series read as an empty checkbox.
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({ 'background-color': 'transparent' });
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
@@ -136,25 +147,33 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('search behavior (RIGHT position)', () => {
|
||||
it('filters legend items based on search query (case-insensitive)', async () => {
|
||||
describe('status readout', () => {
|
||||
it('reports how many series are showing', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 2 of 3 series',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter behavior', () => {
|
||||
it('filters legend items based on the query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'A');
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no legend items match the search query', async () => {
|
||||
it('shows the empty state when nothing matches', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'network');
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
@@ -162,12 +181,11 @@ describe('UPlotLegend', () => {
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
|
||||
it('ignores a whitespace-only query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, ' ');
|
||||
await user.type(screen.getByTestId('legend-search-input'), ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
@@ -178,39 +196,197 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend actions', () => {
|
||||
it('calls onLegendClick when a legend item is clicked', async () => {
|
||||
describe('row interactions', () => {
|
||||
const allShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1], show: true },
|
||||
2: { ...baseLegendItemsMap[2] },
|
||||
};
|
||||
|
||||
const mockAllShown = (): void => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: allShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
it('isolates the series when everything is showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
// Nothing the user can see is there to exclude, so the click means Only.
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles the series once something is already hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(onLegendClick).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeries).toHaveBeenCalledWith(0);
|
||||
expect(onShowOnlySeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls mouseMove when the mouse moves over a legend item', async () => {
|
||||
it('excludes just that series when its marker is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockAllShown();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-marker-0'));
|
||||
|
||||
// The marker is the one way to exclude a single series while
|
||||
// everything is showing — the row click isolates instead.
|
||||
expect(onToggleSeries).toHaveBeenCalledWith(0);
|
||||
expect(onShowOnlySeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the marker offering to hide the last series showing', () => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
|
||||
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('labels the marker with what clicking it does', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Hide A',
|
||||
);
|
||||
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Show B',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the clicked series to the selection while one is alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
},
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is showing alone; clicking another row builds the selection
|
||||
// up rather than moving the isolation.
|
||||
await user.click(screen.getByText('B'));
|
||||
|
||||
expect(onToggleSeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowOnlySeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles the series on Enter and Space', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
row.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
await user.keyboard(' ');
|
||||
|
||||
await user.hover(legendItem);
|
||||
|
||||
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeries).toHaveBeenCalledTimes(2);
|
||||
expect(onToggleSeries).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
|
||||
it('reflects visibility on the row for assistive tech', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores every series from All without also toggling the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const container = document.querySelector('.legend-container') as HTMLElement;
|
||||
// Series 0 is shown while B is hidden, so its action is All.
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
await user.hover(container);
|
||||
await user.unhover(container);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
expect(onToggleSeries).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
it('isolates the series from Only on a hidden row', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-1'));
|
||||
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onToggleSeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('highlights the hovered series and clears it on leave', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
await user.hover(row);
|
||||
expect(onHoverSeries).toHaveBeenCalledWith(0);
|
||||
|
||||
await user.unhover(row);
|
||||
expect(onHoverSeries).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('one-series state', () => {
|
||||
const soleShownItemsMap = {
|
||||
0: { ...baseLegendItemsMap[0] },
|
||||
1: { ...baseLegendItemsMap[1] },
|
||||
2: { ...baseLegendItemsMap[2], show: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: soleShownItemsMap,
|
||||
focusedSeriesIndex: null,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('offers All on the shown row and Only on the hidden ones', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
|
||||
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
|
||||
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
|
||||
});
|
||||
|
||||
it('restores everything from All', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
@@ -118,23 +118,23 @@ export interface LegendConfig {
|
||||
/**
|
||||
* Presentational legend props. Source-agnostic: it renders whatever `items`
|
||||
* it's given and delegates interaction to the container handlers, so it serves
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
|
||||
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
|
||||
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
/**
|
||||
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
|
||||
* handler reads the target's id rather than binding per item.
|
||||
*/
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave: () => void;
|
||||
/** Row click / Space / Enter: hide or show that one series. */
|
||||
onToggleSeries: (seriesIndex: number) => void;
|
||||
/** "Only": show that series alone. */
|
||||
onShowOnlySeries: (seriesIndex: number) => void;
|
||||
/** "All": leave the narrowed selection and show every series. */
|
||||
onShowAllSeries: () => void;
|
||||
/** Row hover, for the chart-side highlight; null on leave. */
|
||||
onHoverSeries: (seriesIndex: number | null) => void;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
|
||||
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
|
||||
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
|
||||
|
||||
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
|
||||
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
|
||||
/** Stroke-width multiplier applied to the series highlighted from the legend. */
|
||||
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
|
||||
|
||||
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {
|
||||
LEGEND_HIGHLIGHT_DIM_ALPHA,
|
||||
LEGEND_HIGHLIGHT_WIDTH_RATIO,
|
||||
} from 'lib/uPlotV2/constants';
|
||||
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
|
||||
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type uPlot from 'uplot';
|
||||
@@ -20,12 +24,26 @@ export interface IPlotContext {
|
||||
setPlotContextInitialState: (state: PlotContextInitialState) => void;
|
||||
onToggleSeriesVisibility: (seriesIndex: number) => void;
|
||||
onToggleSeriesOnOff: (seriesIndex: number) => void;
|
||||
/** Show this series alone. */
|
||||
onShowOnlySeries: (seriesIndex: number) => void;
|
||||
/** Show every series again. */
|
||||
onShowAllSeries: () => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
/** Lift one series above the rest (dim + thicken) without changing visibility. */
|
||||
onHighlightSeries: (seriesIndex: number | null) => void;
|
||||
syncSeriesVisibilityToLocalStorage: () => void;
|
||||
}
|
||||
|
||||
export const PlotContext = createContext<IPlotContext | null>(null);
|
||||
|
||||
/** Data series (index 0 is the x-axis) currently drawn. */
|
||||
const countShownSeries = (plot: uPlot): number =>
|
||||
plot.series.reduce(
|
||||
(count, series, index) =>
|
||||
index > 0 && series.show !== false ? count + 1 : count,
|
||||
0,
|
||||
);
|
||||
|
||||
export const PlotContextProvider = ({
|
||||
children,
|
||||
}: PropsWithChildren): JSX.Element => {
|
||||
@@ -33,6 +51,9 @@ export const PlotContextProvider = ({
|
||||
const activeSeriesIndex = useRef<number | undefined>(undefined);
|
||||
const idRef = useRef<string | undefined>(undefined);
|
||||
const shouldSavePreferencesRef = useRef<boolean>(false);
|
||||
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
|
||||
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
|
||||
const highlightedSeriesIndexRef = useRef<number | null>(null);
|
||||
|
||||
const setPlotContextInitialState = useCallback(
|
||||
({
|
||||
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
baseSeriesWidthsRef.current = new Map();
|
||||
highlightedSeriesIndexRef.current = null;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
@@ -64,6 +87,54 @@ export const PlotContextProvider = ({
|
||||
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
|
||||
}, []);
|
||||
|
||||
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
highlightedSeriesIndexRef.current = seriesIndex;
|
||||
|
||||
plot.series.forEach((series, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
if (!baseSeriesWidthsRef.current.has(index)) {
|
||||
baseSeriesWidthsRef.current.set(index, series.width);
|
||||
}
|
||||
const baseWidth = baseSeriesWidthsRef.current.get(index);
|
||||
const isHighlighted = index === seriesIndex;
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
series.alpha =
|
||||
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
|
||||
series.width =
|
||||
isHighlighted && baseWidth !== undefined
|
||||
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
|
||||
: baseWidth;
|
||||
/* eslint-enable no-param-reassign */
|
||||
});
|
||||
|
||||
// Only the stroke style changed, so the cached paths stay valid.
|
||||
plot.redraw(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Leaving the dim on a hidden series leaves every other one faded, which
|
||||
* reads as an isolation rather than as one series being excluded.
|
||||
*/
|
||||
const clearHighlightIfHidden = useCallback((): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
const highlightedIndex = highlightedSeriesIndexRef.current;
|
||||
if (!plot || highlightedIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plot.series[highlightedIndex]?.show === false) {
|
||||
onHighlightSeries(null);
|
||||
}
|
||||
}, [onHighlightSeries]);
|
||||
|
||||
const onToggleSeriesVisibility = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
@@ -103,14 +174,61 @@ export const PlotContextProvider = ({
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An empty chart is never worth reaching.
|
||||
const isHiding = series.show !== false;
|
||||
if (isHiding && countShownSeries(plot) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
plot.setSeries(seriesIndex, { show: !series.show });
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
/** Applies `resolveShow` to every data series in one batch, then persists. */
|
||||
const setSeriesVisibility = useCallback(
|
||||
(resolveShow: (seriesIndex: number) => boolean): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSeriesIndex.current = undefined;
|
||||
|
||||
plot.batch(() => {
|
||||
plot.series.forEach((_, index) => {
|
||||
if (index === 0) {
|
||||
return;
|
||||
}
|
||||
plot.setSeries(index, { show: resolveShow(index) });
|
||||
});
|
||||
if (idRef.current && shouldSavePreferencesRef.current) {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
}
|
||||
});
|
||||
|
||||
clearHighlightIfHidden();
|
||||
},
|
||||
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
|
||||
);
|
||||
|
||||
const onShowOnlySeries = useCallback(
|
||||
(seriesIndex: number): void => {
|
||||
setSeriesVisibility((index) => index === seriesIndex);
|
||||
},
|
||||
[setSeriesVisibility],
|
||||
);
|
||||
|
||||
const onShowAllSeries = useCallback((): void => {
|
||||
setSeriesVisibility(() => true);
|
||||
}, [setSeriesVisibility]);
|
||||
|
||||
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
|
||||
const plot = uPlotInstanceRef.current;
|
||||
if (!plot) {
|
||||
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
}) as unknown as uPlot;
|
||||
|
||||
interface TestComponentProps {
|
||||
@@ -44,7 +45,10 @@ const TestComponent = ({
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
@@ -84,6 +88,13 @@ const TestComponent = ({
|
||||
>
|
||||
Toggle on/off 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-2"
|
||||
onClick={(): void => onToggleSeriesOnOff(2)}
|
||||
>
|
||||
Toggle on/off 2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-5"
|
||||
@@ -98,6 +109,34 @@ const TestComponent = ({
|
||||
>
|
||||
Focus series
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-only-1"
|
||||
onClick={(): void => onShowOnlySeries(1)}
|
||||
>
|
||||
Show only 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="show-all"
|
||||
onClick={(): void => onShowAllSeries()}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="highlight-1"
|
||||
onClick={(): void => onHighlightSeries(1)}
|
||||
>
|
||||
Highlight 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="clear-highlight"
|
||||
onClick={(): void => onHighlightSeries(null)}
|
||||
>
|
||||
Clear highlight
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -343,6 +384,48 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to hide the last series showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// An empty chart is never a state worth reaching.
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still shows a hidden series when only one is left showing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
@@ -381,4 +464,193 @@ describe('PlotContext', () => {
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
|
||||
});
|
||||
});
|
||||
describe('onShowOnlySeries', () => {
|
||||
const renderWithSeries = (
|
||||
series: MockSeries[],
|
||||
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
return { plot, user };
|
||||
};
|
||||
|
||||
it('hides every other series, leaving the x-axis alone', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps isolating the series that is already the only one shown', async () => {
|
||||
const { plot, user } = renderWithSeries([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-only-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onShowAllSeries', () => {
|
||||
it('shows every hidden series again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('show-all'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
|
||||
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('onHighlightSeries', () => {
|
||||
const series = (): MockSeries[] => [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true, width: 2 },
|
||||
{ label: 'Memory', show: true, width: 2 },
|
||||
];
|
||||
|
||||
it('dims the other series and thickens the highlighted one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(3.2);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
// Only the stroke changed, so the cached paths are reused.
|
||||
expect(plot.redraw).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('restores every series when the highlight is cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('clear-highlight'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[1].width).toBe(2);
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the dim when the highlighted series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
// Otherwise every remaining series stays faded and the panel reads as
|
||||
// an isolation instead of one series being excluded.
|
||||
expect(plot.series[2].alpha).toBe(1);
|
||||
expect(plot.series[2].width).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the dim when a different series is hidden', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
(plot.setSeries as jest.Mock).mockImplementation(
|
||||
(index: number, opts: { show?: boolean }) => {
|
||||
if (typeof opts.show === 'boolean') {
|
||||
(plot.series[index] as MockSeries).show = opts.show;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-2'));
|
||||
|
||||
expect(plot.series[1].alpha).toBe(1);
|
||||
expect(plot.series[2].alpha).toBe(0.16);
|
||||
});
|
||||
|
||||
it('leaves visibility untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot(series());
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('highlight-1'));
|
||||
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onHighlightSeries: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -37,15 +39,20 @@ describe('useLegendActions', () => {
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onHighlightSeries = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
@@ -53,149 +60,65 @@ describe('useLegendActions', () => {
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
describe('visibility actions', () => {
|
||||
it('toggles a single series on row click', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
result.current.onToggleSeries(2);
|
||||
|
||||
describe('onLegendClick', () => {
|
||||
it('toggles series visibility when clicking on legend label', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles series on/off when clicking on marker', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(
|
||||
createMouseEvent({ legendItemId: 0, isMarker: true }),
|
||||
);
|
||||
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
|
||||
// The row must never isolate — that is what "Only" is for.
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('forwards the Only and All actions to the plot', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
result.current.onShowOnlySeries(1);
|
||||
result.current.onShowAllSeries();
|
||||
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
describe('hover highlight', () => {
|
||||
it('highlights the hovered series', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onHoverSeries(2);
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
it('clears the highlight on leave', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
result.current.onHoverSeries(null);
|
||||
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('coalesces rapid hovers into one frame', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
|
||||
result.current.onHoverSeries(1);
|
||||
result.current.onHoverSeries(2);
|
||||
|
||||
// Each new hover cancels the frame the previous one queued.
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
it('cancels a pending highlight frame on unmount', () => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((): number => 7);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
const { result, unmount } = renderHook(() => useLegendActions());
|
||||
result.current.onHoverSeries(1);
|
||||
unmount();
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does nothing when hovering over already focused series', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
|
||||
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseLeave', () => {
|
||||
it('cancels pending animation frame and clears focus state', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseLeave();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,117 +1,54 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
|
||||
export function useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
}: {
|
||||
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
|
||||
focusedSeriesIndex: number | null;
|
||||
}): {
|
||||
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onFocusSeries: (seriesIndex: number | null) => void;
|
||||
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
} {
|
||||
export interface UseLegendActionsResult {
|
||||
onToggleSeries: (seriesIndex: number) => void;
|
||||
/** Show this series alone. */
|
||||
onShowOnlySeries: (seriesIndex: number) => void;
|
||||
/** Leave the narrowed selection and show every series. */
|
||||
onShowAllSeries: () => void;
|
||||
/** null clears the highlight. */
|
||||
onHoverSeries: (seriesIndex: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
|
||||
* to one chart redraw per frame.
|
||||
*/
|
||||
export function useLegendActions(): UseLegendActionsResult {
|
||||
const {
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onToggleSeriesOnOff,
|
||||
onToggleSeriesVisibility,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
|
||||
const rafId = useRef<number | null>(null); // requestAnimationFrame id
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
|
||||
const getLegendItemIdFromEvent = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
const cancelPendingHighlight = useCallback((): void => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const legendItemElement = target.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
|
||||
return legendItemElement?.dataset.legendItemId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
if (!legendItemId) {
|
||||
return;
|
||||
}
|
||||
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
const seriesIndex = Number(legendItemId);
|
||||
|
||||
if (isLegendMarker) {
|
||||
onToggleSeriesOnOff(seriesIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
onToggleSeriesVisibility(seriesIndex);
|
||||
},
|
||||
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
|
||||
);
|
||||
|
||||
const onFocusSeries = useCallback(
|
||||
const onHoverSeries = useCallback(
|
||||
(seriesIndex: number | null): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
setFocusedSeriesIndex(seriesIndex);
|
||||
onFocusSeriesPlot(seriesIndex);
|
||||
cancelPendingHighlight();
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
onHighlightSeries(seriesIndex);
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeriesPlot],
|
||||
[cancelPendingHighlight, onHighlightSeries],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
const seriesIndex = legendItemId ? Number(legendItemId) : null;
|
||||
if (seriesIndex === focusedSeriesIndex) {
|
||||
return;
|
||||
}
|
||||
onFocusSeries(seriesIndex);
|
||||
};
|
||||
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
|
||||
|
||||
const onLegendMouseLeave = useCallback(
|
||||
(): void => {
|
||||
// Cancel any pending RAF from handleFocusSeries to prevent race condition
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
}
|
||||
setFocusedSeriesIndex(null);
|
||||
onFocusSeries(null);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeries],
|
||||
);
|
||||
|
||||
// Cleanup pending animation frames on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
return {
|
||||
onLegendClick,
|
||||
onFocusSeries,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onToggleSeries: onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,9 +45,10 @@ export default function Pie({
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
@@ -227,9 +228,10 @@ export default function Pie({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
onToggleSeries={onToggleSeries}
|
||||
onShowOnlySeries={onShowOnlySeries}
|
||||
onShowAllSeries={onShowAllSeries}
|
||||
onHoverSeries={onHoverSeries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +100,29 @@ describe('Pie', () => {
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
it('isolates a slice when its legend row is clicked with everything showing', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
|
||||
// Nothing visible to exclude, so the click isolates: one arc left.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes a slice when its legend row is clicked with others already hidden', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
|
||||
// Isolate, then add a second slice back, so nothing is isolated any more.
|
||||
fireEvent.click(screen.getByTestId('legend-item-1'));
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByTestId('legend-item-0'));
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import {
|
||||
calculateAverageLegendWidth,
|
||||
calculateChartDimensions,
|
||||
} from 'lib/visualization/charts/utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.width).toBe(784);
|
||||
});
|
||||
|
||||
it('RIGHT: never shrinks the column below the 150px floor', () => {
|
||||
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(3, 3),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(850);
|
||||
expect(dims.legendWidth).toBe(190);
|
||||
expect(dims.width).toBe(810);
|
||||
});
|
||||
|
||||
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
|
||||
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 300,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(120);
|
||||
expect(dims.width).toBe(180);
|
||||
// 40% is 120px, too narrow for the column's own toolbar.
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(150);
|
||||
});
|
||||
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 200,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(100);
|
||||
expect(dims.width).toBe(100);
|
||||
});
|
||||
|
||||
it('BOTTOM: items that fit one row reserve exactly one row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One row = line height (28) + padding (12).
|
||||
// One 28px row + the wrapper's 12px bottom padding.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
it('BOTTOM: more items than one row reserve exactly two rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
|
||||
// clipped third row, and none left over.
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(430);
|
||||
});
|
||||
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
// 1000px wide fits 5 of these per row, so 6 items need a second row.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 160,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(6),
|
||||
});
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 120,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Without the height-relative cap the legend would take 68px of a 160px
|
||||
// panel and the chart (pie especially) collapses to a sliver.
|
||||
expect(dims.legendHeight).toBe(48); // 30% of 160
|
||||
expect(dims.height).toBe(112);
|
||||
// A whole row goes rather than a clipped one being reserved.
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAverageLegendWidth', () => {
|
||||
it('scales with the label length', () => {
|
||||
// 16px of chrome + 30 chars at 8px.
|
||||
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
|
||||
});
|
||||
|
||||
it('never drops below what a row needs to contain its hover actions', () => {
|
||||
// Short or unnamed series would otherwise size a column the actions
|
||||
// escape, spilling over the item beside it.
|
||||
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
|
||||
expect(calculateAverageLegendWidth([''])).toBe(110);
|
||||
});
|
||||
|
||||
it('keeps the default estimate when there are no labels to measure', () => {
|
||||
expect(calculateAverageLegendWidth([])).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
MAX_LEGEND_WIDTH,
|
||||
} from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
// Two rows are worth having, but not at the cost of half the panel.
|
||||
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
|
||||
|
||||
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
|
||||
const MAX_RIGHT_LEGEND_WIDTH = 320;
|
||||
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
|
||||
// Column padding + copy button, not covered by the text-length estimate.
|
||||
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
|
||||
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
|
||||
const MIN_RIGHT_LEGEND_WIDTH = 190;
|
||||
// Past this the split inverts and the chart becomes the smaller half.
|
||||
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Calculates the average width of the legend items based on the labels of the series.
|
||||
* Never returns less than a legend row needs to hold its own hover actions.
|
||||
* @param legends - The labels of the series.
|
||||
* @returns The average width of the legend items.
|
||||
*/
|
||||
export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
if (legends.length === 0) {
|
||||
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +70,9 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
* - Chart width is `containerWidth - legendWidth`.
|
||||
* - BOTTOM legend:
|
||||
* - Computes how many items fit per row, then uses at most 2 rows.
|
||||
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
|
||||
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
|
||||
* the rectangle never clips a row or reserves space for half of one. Two
|
||||
* rows that would take half a short panel fall back to one row.
|
||||
* - Chart height is `containerHeight - legendHeight`, never below 0.
|
||||
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
|
||||
*
|
||||
@@ -100,9 +120,14 @@ export function calculateChartDimensions({
|
||||
MAX_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
|
||||
);
|
||||
// The column's chrome outranks the 40% share on a narrow panel.
|
||||
const floorWidth = Math.min(
|
||||
MIN_RIGHT_LEGEND_WIDTH,
|
||||
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
|
||||
);
|
||||
const rightLegendWidth = Math.min(
|
||||
Math.max(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
|
||||
Math.max(floorWidth, maxRightLegendWidth),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
);
|
||||
|
||||
const legendRowCount = Math.min(
|
||||
2,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
const heightForRows = (rowCount: number): number =>
|
||||
rowCount * LEGEND_ROW_HEIGHT +
|
||||
(rowCount - 1) * LEGEND_ROW_GAP +
|
||||
LEGEND_PADDING;
|
||||
|
||||
const neededRowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
),
|
||||
);
|
||||
|
||||
const idealBottomLegendHeight =
|
||||
legendRowCount > 1
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
// the chart — the pie donut especially — collapses to a sliver. Dropping a
|
||||
// whole row beats clipping one.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
? 1
|
||||
: neededRowCount;
|
||||
|
||||
// Cap at two rows / 80px, and never more than 30% of the container height
|
||||
// (the doc above always promised the %-cap; without it, short grid panels
|
||||
// hand most of their area to the legend and the chart — the pie donut
|
||||
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
|
||||
const maxAllowedLegendHeight = Math.min(
|
||||
2 * legendRowHeight,
|
||||
80,
|
||||
Math.floor(containerHeight * 0.3),
|
||||
);
|
||||
|
||||
const bottomLegendHeight = Math.min(
|
||||
idealBottomLegendHeight,
|
||||
maxAllowedLegendHeight,
|
||||
);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { PieSlice } from 'lib/visualization/charts/types';
|
||||
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
|
||||
@@ -24,22 +23,6 @@ const DATA: PieSlice[] = [
|
||||
{ label: 'checkout', value: 40, color: '#c' },
|
||||
];
|
||||
|
||||
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
|
||||
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
|
||||
function legendEvent(
|
||||
index: number | null,
|
||||
isMarker = false,
|
||||
): MouseEvent<HTMLDivElement> {
|
||||
const itemEl =
|
||||
index == null ? null : { dataset: { legendItemId: String(index) } };
|
||||
return {
|
||||
target: {
|
||||
closest: (): unknown => itemEl,
|
||||
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
|
||||
},
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
describe('usePieInteractions', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStored.mockReturnValue(null);
|
||||
@@ -59,11 +42,11 @@ describe('usePieInteractions', () => {
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
describe('row toggle', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
@@ -73,18 +56,30 @@ describe('usePieInteractions', () => {
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
describe('the last slice showing', () => {
|
||||
it('cannot be hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
act(() => result.current.onShowOnlySeries(0));
|
||||
act(() => result.current.onToggleSeries(0));
|
||||
|
||||
// An empty donut is never a state worth reaching.
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Only', () => {
|
||||
it('isolates the slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onShowOnlySeries(0));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
@@ -92,8 +87,24 @@ describe('usePieInteractions', () => {
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
it('switches the isolation to another slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onShowOnlySeries(0));
|
||||
act(() => result.current.onShowOnlySeries(2));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('All', () => {
|
||||
it('brings every hidden slice back', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onShowOnlySeries(0));
|
||||
act(() => result.current.onShowAllSeries());
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
@@ -103,11 +114,23 @@ describe('usePieInteractions', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
act(() => result.current.onHoverSeries(2));
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
act(() => result.current.onHoverSeries(null));
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the focus when the focused slice is hidden', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onHoverSeries(1));
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
|
||||
// Otherwise every remaining arc stays dimmed and the donut reads as an
|
||||
// isolation instead of one slice being excluded.
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
@@ -115,8 +138,8 @@ describe('usePieInteractions', () => {
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
act(() => result.current.onHoverSeries(1));
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
@@ -125,7 +148,7 @@ describe('usePieInteractions', () => {
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
act(() => result.current.onToggleSeries(0));
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
@@ -18,27 +18,17 @@ export interface UsePieInteractionsResult {
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
onToggleSeries: (sliceIndex: number) => void;
|
||||
onShowOnlySeries: (sliceIndex: number) => void;
|
||||
onShowAllSeries: () => void;
|
||||
onHoverSeries: (sliceIndex: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
|
||||
* shared legend's actions, and persistence of the hidden set to localStorage
|
||||
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
|
||||
* slices, legend items, focus index, and the legend handlers.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
@@ -48,7 +38,6 @@ export function usePieInteractions(
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
@@ -104,65 +93,69 @@ export function usePieInteractions(
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
const onHoverSeries = useCallback(
|
||||
(sliceIndex: number | null): void => {
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
setActive(
|
||||
sliceIndex != null && !hiddenIndices.has(sliceIndex)
|
||||
? data[sliceIndex]
|
||||
: null,
|
||||
);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
const onToggleSeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(sliceIndex)) {
|
||||
next.delete(sliceIndex);
|
||||
} else {
|
||||
// An empty donut is never worth reaching.
|
||||
if (data.length - next.size <= 1) {
|
||||
return;
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
next.add(sliceIndex);
|
||||
}
|
||||
applyHidden(next);
|
||||
},
|
||||
[data.length, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const onShowOnlySeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
data.forEach((_, index) => {
|
||||
if (index !== sliceIndex) {
|
||||
next.add(index);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
[data, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
const onShowAllSeries = useCallback(
|
||||
(): void => applyHidden(new Set()),
|
||||
[applyHidden],
|
||||
);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
const activeIndex = active ? data.indexOf(active) : -1;
|
||||
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
|
||||
// isolation rather than as one slice being excluded.
|
||||
const effectiveActive =
|
||||
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
|
||||
const focusedIndex = effectiveActive ? activeIndex : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
active: effectiveActive,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user