mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 07:32:12 +08:00
UI/Plot: Implement keyboard controls for plot cursor (#42244)
This commit is contained in:
@ -1,7 +1,11 @@
|
|||||||
import React, { FC, CSSProperties, ComponentType } from 'react';
|
import React, { FC, CSSProperties, ComponentType } from 'react';
|
||||||
import { useMeasure } from 'react-use';
|
import { useMeasure } from 'react-use';
|
||||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
import { css } from '@emotion/css';
|
||||||
import { LegendPlacement } from '@grafana/schema';
|
import { LegendPlacement } from '@grafana/schema';
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
|
import { getFocusStyles } from '../../themes/mixins';
|
||||||
|
import { useStyles2 } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @beta
|
* @beta
|
||||||
@ -24,6 +28,7 @@ export interface VizLayoutComponentType extends FC<VizLayoutProps> {
|
|||||||
* @beta
|
* @beta
|
||||||
*/
|
*/
|
||||||
export const VizLayout: VizLayoutComponentType = ({ width, height, legend, children }) => {
|
export const VizLayout: VizLayoutComponentType = ({ width, height, legend, children }) => {
|
||||||
|
const styles = useStyles2(getVizStyles);
|
||||||
const containerStyle: CSSProperties = {
|
const containerStyle: CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
@ -32,17 +37,17 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
|||||||
const [legendRef, legendMeasure] = useMeasure<HTMLDivElement>();
|
const [legendRef, legendMeasure] = useMeasure<HTMLDivElement>();
|
||||||
|
|
||||||
if (!legend) {
|
if (!legend) {
|
||||||
return <div style={containerStyle}>{children(width, height)}</div>;
|
return (
|
||||||
|
<div tabIndex={0} style={containerStyle} className={styles.viz}>
|
||||||
|
{children(width, height)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { placement, maxHeight = '35%', maxWidth = '60%' } = legend.props;
|
const { placement, maxHeight = '35%', maxWidth = '60%' } = legend.props;
|
||||||
|
|
||||||
let size: VizSize | null = null;
|
let size: VizSize | null = null;
|
||||||
|
|
||||||
const vizStyle: CSSProperties = {
|
|
||||||
flexGrow: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const legendStyle: CSSProperties = {};
|
const legendStyle: CSSProperties = {};
|
||||||
|
|
||||||
switch (placement) {
|
switch (placement) {
|
||||||
@ -76,7 +81,9 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
<div style={vizStyle}>{size && children(size.width, size.height)}</div>
|
<div tabIndex={0} className={styles.viz}>
|
||||||
|
{size && children(size.width, size.height)}
|
||||||
|
</div>
|
||||||
<div style={legendStyle} ref={legendRef}>
|
<div style={legendStyle} ref={legendRef}>
|
||||||
<CustomScrollbar hideHorizontalTrack>{legend}</CustomScrollbar>
|
<CustomScrollbar hideHorizontalTrack>{legend}</CustomScrollbar>
|
||||||
</div>
|
</div>
|
||||||
@ -84,6 +91,15 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getVizStyles = (theme: GrafanaTheme2) => {
|
||||||
|
return {
|
||||||
|
viz: css({
|
||||||
|
flexGrow: 2,
|
||||||
|
borderRadius: theme.shape.borderRadius(1),
|
||||||
|
'&:focus-visible': getFocusStyles(theme),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
interface VizSize {
|
interface VizSize {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
@ -0,0 +1,160 @@
|
|||||||
|
import React, { useLayoutEffect } from 'react';
|
||||||
|
import { clamp } from 'lodash';
|
||||||
|
import uPlot from 'uplot';
|
||||||
|
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||||
|
|
||||||
|
interface KeyboardPluginProps {
|
||||||
|
config: UPlotConfigBuilder; // onkeypress, onkeyup, onkeydown (triggered by vizlayout handlers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PIXELS_PER_MS = 0.1 as const;
|
||||||
|
const SHIFT_MULTIPLIER = 2 as const;
|
||||||
|
const KNOWN_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'Shift', ' ']);
|
||||||
|
|
||||||
|
const initHook = (u: uPlot) => {
|
||||||
|
let vizLayoutViz: HTMLElement | null = u.root.closest('[tabindex]');
|
||||||
|
let pressedKeys = new Set<string>();
|
||||||
|
let dragStartX: number | null = null;
|
||||||
|
let keysLastHandledAt: number | null = null;
|
||||||
|
|
||||||
|
if (!vizLayoutViz) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveCursor = (dx: number, dy: number) => {
|
||||||
|
const { cursor } = u;
|
||||||
|
if (cursor.left === undefined || cursor.top === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = u.over.style;
|
||||||
|
const [maxX, maxY] = [Math.floor(parseFloat(width)), Math.floor(parseFloat(height))];
|
||||||
|
|
||||||
|
u.setCursor({
|
||||||
|
left: clamp(cursor.left + dx, 0, maxX),
|
||||||
|
top: clamp(cursor.top + dy, 0, maxY),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressedKeys = (time: number) => {
|
||||||
|
const nothingPressed = pressedKeys.size === 0;
|
||||||
|
if (nothingPressed || !u) {
|
||||||
|
keysLastHandledAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = time - (keysLastHandledAt ?? time);
|
||||||
|
const dx = dt * PIXELS_PER_MS;
|
||||||
|
let horValue = 0;
|
||||||
|
let vertValue = 0;
|
||||||
|
|
||||||
|
if (pressedKeys.has('ArrowUp')) {
|
||||||
|
vertValue -= dx;
|
||||||
|
}
|
||||||
|
if (pressedKeys.has('ArrowDown')) {
|
||||||
|
vertValue += dx;
|
||||||
|
}
|
||||||
|
if (pressedKeys.has('ArrowLeft')) {
|
||||||
|
horValue -= dx;
|
||||||
|
}
|
||||||
|
if (pressedKeys.has('ArrowRight')) {
|
||||||
|
horValue += dx;
|
||||||
|
}
|
||||||
|
if (pressedKeys.has('Shift')) {
|
||||||
|
horValue *= SHIFT_MULTIPLIER;
|
||||||
|
vertValue *= SHIFT_MULTIPLIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCursor(horValue, vertValue);
|
||||||
|
|
||||||
|
const { cursor } = u;
|
||||||
|
if (pressedKeys.has(' ') && cursor) {
|
||||||
|
const drawHeight = Number(u.over.style.height.slice(0, -2));
|
||||||
|
|
||||||
|
u.setSelect(
|
||||||
|
{
|
||||||
|
left: cursor.left! < dragStartX! ? cursor.left! : dragStartX!,
|
||||||
|
top: 0,
|
||||||
|
width: Math.abs(cursor.left! - (dragStartX ?? cursor.left!)),
|
||||||
|
height: drawHeight,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
keysLastHandledAt = time;
|
||||||
|
window.requestAnimationFrame(handlePressedKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
vizLayoutViz.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
// Hide the cursor if the user tabs away
|
||||||
|
u.setCursor({ left: -5, top: -5 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!KNOWN_KEYS.has(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const newKey = !pressedKeys.has(e.key);
|
||||||
|
if (newKey) {
|
||||||
|
const initiateAnimationLoop = pressedKeys.size === 0;
|
||||||
|
pressedKeys.add(e.key);
|
||||||
|
|
||||||
|
dragStartX = e.key === ' ' && dragStartX === null ? u.cursor.left! : dragStartX;
|
||||||
|
|
||||||
|
if (initiateAnimationLoop) {
|
||||||
|
window.requestAnimationFrame(handlePressedKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vizLayoutViz.addEventListener('keyup', (e) => {
|
||||||
|
if (!KNOWN_KEYS.has(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pressedKeys.delete(e.key);
|
||||||
|
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// We do this so setSelect hooks get fired, zooming the plot
|
||||||
|
u.setSelect(u.select);
|
||||||
|
dragStartX = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vizLayoutViz.addEventListener('focus', (e) => {
|
||||||
|
// We only want to initialize the cursor if the user is using keyboard controls
|
||||||
|
if (!vizLayoutViz?.matches(':focus-visible')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is there a more idiomatic way to do this?
|
||||||
|
const drawWidth = parseFloat(u.over.style.width);
|
||||||
|
const drawHeight = parseFloat(u.over.style.height);
|
||||||
|
u.setCursor({ left: drawWidth / 2, top: drawHeight / 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
vizLayoutViz.addEventListener('blur', (e) => {
|
||||||
|
keysLastHandledAt = null;
|
||||||
|
dragStartX = null;
|
||||||
|
pressedKeys.clear();
|
||||||
|
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
|
export const KeyboardPlugin: React.FC<KeyboardPluginProps> = ({ config }) => {
|
||||||
|
useLayoutEffect(() => config.addHook('init', initHook), [config]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
@ -66,7 +66,15 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
let bbox: DOMRect | undefined = undefined;
|
let bbox: DOMRect | undefined = undefined;
|
||||||
|
|
||||||
const plotMouseLeave = () => {
|
const plotEnter = () => {
|
||||||
|
if (!isMounted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsActive(true);
|
||||||
|
plotInstance.current?.root.classList.add('plot-active');
|
||||||
|
};
|
||||||
|
|
||||||
|
const plotLeave = () => {
|
||||||
if (!isMounted()) {
|
if (!isMounted()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -75,22 +83,17 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
|||||||
plotInstance.current?.root.classList.remove('plot-active');
|
plotInstance.current?.root.classList.remove('plot-active');
|
||||||
};
|
};
|
||||||
|
|
||||||
const plotMouseEnter = () => {
|
|
||||||
if (!isMounted()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsActive(true);
|
|
||||||
plotInstance.current?.root.classList.add('plot-active');
|
|
||||||
};
|
|
||||||
|
|
||||||
// cache uPlot plotting area bounding box
|
// cache uPlot plotting area bounding box
|
||||||
config.addHook('syncRect', (u, rect) => (bbox = rect));
|
config.addHook('syncRect', (u, rect) => (bbox = rect));
|
||||||
|
|
||||||
config.addHook('init', (u) => {
|
config.addHook('init', (u) => {
|
||||||
plotInstance.current = u;
|
plotInstance.current = u;
|
||||||
|
|
||||||
u.over.addEventListener('mouseleave', plotMouseLeave);
|
u.root.parentElement?.addEventListener('focus', plotEnter);
|
||||||
u.over.addEventListener('mouseenter', plotMouseEnter);
|
u.over.addEventListener('mouseenter', plotEnter);
|
||||||
|
|
||||||
|
u.root.parentElement?.addEventListener('blur', plotLeave);
|
||||||
|
u.over.addEventListener('mouseleave', plotLeave);
|
||||||
|
|
||||||
if (sync === DashboardCursorSync.Crosshair) {
|
if (sync === DashboardCursorSync.Crosshair) {
|
||||||
u.root.classList.add('shared-crosshair');
|
u.root.classList.add('shared-crosshair');
|
||||||
@ -157,8 +160,10 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
setCoords(null);
|
setCoords(null);
|
||||||
if (plotInstance.current) {
|
if (plotInstance.current) {
|
||||||
plotInstance.current.over.removeEventListener('mouseleave', plotMouseLeave);
|
plotInstance.current.over.removeEventListener('mouseleave', plotLeave);
|
||||||
plotInstance.current.over.removeEventListener('mouseenter', plotMouseEnter);
|
plotInstance.current.over.removeEventListener('mouseenter', plotEnter);
|
||||||
|
plotInstance.current.root.parentElement?.removeEventListener('focus', plotEnter);
|
||||||
|
plotInstance.current.root.parentElement?.removeEventListener('blur', plotLeave);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [config, setCoords, setIsActive, setFocusedPointIdx, setFocusedPointIdxs]);
|
}, [config, setCoords, setIsActive, setFocusedPointIdx, setFocusedPointIdxs]);
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export { ZoomPlugin } from './ZoomPlugin';
|
export { ZoomPlugin } from './ZoomPlugin';
|
||||||
export { TooltipPlugin } from './TooltipPlugin';
|
export { TooltipPlugin } from './TooltipPlugin';
|
||||||
|
export { KeyboardPlugin } from './KeyboardPlugin';
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Field, PanelProps } from '@grafana/data';
|
import { Field, PanelProps } from '@grafana/data';
|
||||||
import { TooltipDisplayMode } from '@grafana/schema';
|
import { TooltipDisplayMode } from '@grafana/schema';
|
||||||
import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin } from '@grafana/ui';
|
import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin, KeyboardPlugin } from '@grafana/ui';
|
||||||
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
|
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
|
||||||
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||||
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
||||||
@ -54,6 +54,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
{(config, alignedDataFrame) => {
|
{(config, alignedDataFrame) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<KeyboardPlugin config={config} />
|
||||||
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
|
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
|
||||||
{options.tooltip.mode === TooltipDisplayMode.None || (
|
{options.tooltip.mode === TooltipDisplayMode.None || (
|
||||||
<TooltipPlugin
|
<TooltipPlugin
|
||||||
|
Reference in New Issue
Block a user