Compare commits

...

7 Commits

Author SHA1 Message Date
Abhi Kumar
dbdb55b434 feat(dashboards): build a series selection from the legend
The row action is now a labelled All/Only toggle, and clicking rows in a
narrowed legend adds to the selection instead of moving the isolation,
which leaves Add with nothing to do. Column floors grew to fit the wider
action.

Assisted-by: Claude Opus 5
2026-09-11 14:17:34 +05:30
Abhi Kumar
d051430206 chore: minor label change 2026-09-11 13:26:24 +05:30
Abhi Kumar
953e69baec fix(dashboards): cap the width of the legend row action tooltips
The isolate tooltip names the series too, so it needs the same wrap as
the label's.
2026-09-11 13:26:24 +05:30
Abhi Kumar
5f5e3dea2a fix(dashboards): wrap long series names in the legend tooltip
Series names have no spaces to break on, so the tooltip needs a width
cap and a break rule to stop it becoming one panel-wide line.
2026-09-11 13:26:24 +05:30
Abhi Kumar
336b9e8d8b style(dashboards): match tooltip series rows to the legend
Same filled rounded marker and mono type, so a series reads the same in
the tooltip as in the legend.
2026-09-11 13:26:24 +05:30
Abhi Kumar
f5083f220a fix(dashboards): reserve exactly the rows the bottom legend shows
The reserved height came from a 40px row estimate while rows are 28px
with a 2px gap, so two rows never quite fit and one row over-reserved.
Two rows that would take half a short panel fall back to one, and a
column can no longer be sized narrower than the row's hover actions.
2026-09-11 13:26:24 +05:30
Abhi Kumar
67293da003 feat(dashboards): rework panel chart legend interaction
What a row click means now depends on how many series are shown: isolate
when all are visible, move the isolation while one is, toggle otherwise.
The marker stays a separate target so a single series can still be
excluded in one click, and at least one series always stays visible.

Only/Add are derived from the shown count rather than a remembered
isolation, so hiding series one at a time behaves the same as isolating.
2026-09-11 13:26:24 +05:30
26 changed files with 1700 additions and 759 deletions

View 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);
}

View File

@@ -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;
}
}
}

View File

@@ -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 &quot;{legendSearchQuery}&quot;
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
</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)}
/>

View 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);

View 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>
</>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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);
});
});

View 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;

View 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),
);
}

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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();
});
});
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,
],
);

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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();
});

View File

@@ -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,
};
}

View File

@@ -29,7 +29,6 @@
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
padding: 0 12px 12px 12px;
}
}

View File

@@ -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';