mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 05:12:36 +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 { useMeasure } from 'react-use';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { css } from '@emotion/css';
|
||||
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
|
||||
@ -24,6 +28,7 @@ export interface VizLayoutComponentType extends FC<VizLayoutProps> {
|
||||
* @beta
|
||||
*/
|
||||
export const VizLayout: VizLayoutComponentType = ({ width, height, legend, children }) => {
|
||||
const styles = useStyles2(getVizStyles);
|
||||
const containerStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
width: `${width}px`,
|
||||
@ -32,17 +37,17 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
||||
const [legendRef, legendMeasure] = useMeasure<HTMLDivElement>();
|
||||
|
||||
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;
|
||||
|
||||
let size: VizSize | null = null;
|
||||
|
||||
const vizStyle: CSSProperties = {
|
||||
flexGrow: 2,
|
||||
};
|
||||
|
||||
const legendStyle: CSSProperties = {};
|
||||
|
||||
switch (placement) {
|
||||
@ -76,7 +81,9 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<CustomScrollbar hideHorizontalTrack>{legend}</CustomScrollbar>
|
||||
</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 {
|
||||
width: 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(() => {
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
@ -75,22 +83,17 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
||||
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
|
||||
config.addHook('syncRect', (u, rect) => (bbox = rect));
|
||||
|
||||
config.addHook('init', (u) => {
|
||||
plotInstance.current = u;
|
||||
|
||||
u.over.addEventListener('mouseleave', plotMouseLeave);
|
||||
u.over.addEventListener('mouseenter', plotMouseEnter);
|
||||
u.root.parentElement?.addEventListener('focus', plotEnter);
|
||||
u.over.addEventListener('mouseenter', plotEnter);
|
||||
|
||||
u.root.parentElement?.addEventListener('blur', plotLeave);
|
||||
u.over.addEventListener('mouseleave', plotLeave);
|
||||
|
||||
if (sync === DashboardCursorSync.Crosshair) {
|
||||
u.root.classList.add('shared-crosshair');
|
||||
@ -157,8 +160,10 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
||||
return () => {
|
||||
setCoords(null);
|
||||
if (plotInstance.current) {
|
||||
plotInstance.current.over.removeEventListener('mouseleave', plotMouseLeave);
|
||||
plotInstance.current.over.removeEventListener('mouseenter', plotMouseEnter);
|
||||
plotInstance.current.over.removeEventListener('mouseleave', plotLeave);
|
||||
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]);
|
||||
|
@ -1,2 +1,3 @@
|
||||
export { ZoomPlugin } from './ZoomPlugin';
|
||||
export { TooltipPlugin } from './TooltipPlugin';
|
||||
export { KeyboardPlugin } from './KeyboardPlugin';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Field, PanelProps } from '@grafana/data';
|
||||
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 { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
|
||||
import { ContextMenuPlugin } from './plugins/ContextMenuPlugin';
|
||||
@ -54,6 +54,7 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
{(config, alignedDataFrame) => {
|
||||
return (
|
||||
<>
|
||||
<KeyboardPlugin config={config} />
|
||||
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
|
||||
{options.tooltip.mode === TooltipDisplayMode.None || (
|
||||
<TooltipPlugin
|
||||
|
Reference in New Issue
Block a user