1import React, { useMemo, useState } from 'react';
2import { css } from '@emotion/css';
3import { Threshold, GrafanaTheme2 } from '@grafana/data';
4import { useStyles2, useTheme2 } from '@grafana/ui';
5import Draggable, { DraggableBounds } from 'react-draggable';
6
7type OutOfBounds = 'top' | 'bottom' | 'none';
8
9interface ThresholdDragHandleProps {
10  step: Threshold;
11  y: number;
12  dragBounds: DraggableBounds;
13  mapPositionToValue: (y: number) => number;
14  onChange: (value: number) => void;
15  formatValue: (value: number) => string;
16}
17
18export const ThresholdDragHandle: React.FC<ThresholdDragHandleProps> = ({
19  step,
20  y,
21  dragBounds,
22  mapPositionToValue,
23  formatValue,
24  onChange,
25}) => {
26  const theme = useTheme2();
27  let yPos = y;
28  let outOfBounds: OutOfBounds = 'none';
29
30  if (y < (dragBounds.top ?? 0)) {
31    outOfBounds = 'top';
32  }
33
34  // there seems to be a 22px offset at the bottom where the threshold line is still drawn
35  // this is probably offset by the size of the x-axis component
36  if (y > (dragBounds.bottom ?? 0) + 22) {
37    outOfBounds = 'bottom';
38  }
39
40  if (outOfBounds === 'bottom') {
41    yPos = dragBounds.bottom ?? y;
42  }
43
44  if (outOfBounds === 'top') {
45    yPos = dragBounds.top ?? y;
46  }
47
48  const styles = useStyles2((theme) => getStyles(theme, step, outOfBounds));
49  const [currentValue, setCurrentValue] = useState(step.value);
50
51  const textColor = useMemo(() => {
52    return theme.colors.getContrastText(theme.visualization.getColorByName(step.color));
53  }, [step.color, theme]);
54
55  return (
56    <Draggable
57      axis="y"
58      grid={[1, 1]}
59      onStop={(_e, d) => {
60        onChange(mapPositionToValue(d.lastY));
61        // as of https://github.com/react-grid-layout/react-draggable/issues/390#issuecomment-623237835
62        return false;
63      }}
64      onDrag={(_e, d) => setCurrentValue(mapPositionToValue(d.lastY))}
65      position={{ x: 0, y: yPos }}
66      bounds={dragBounds}
67    >
68      <div className={styles.handle} style={{ color: textColor }}>
69        <span className={styles.handleText}>{formatValue(currentValue)}</span>
70      </div>
71    </Draggable>
72  );
73};
74
75ThresholdDragHandle.displayName = 'ThresholdDragHandle';
76
77const getStyles = (theme: GrafanaTheme2, step: Threshold, outOfBounds: OutOfBounds) => {
78  const mainColor = theme.visualization.getColorByName(step.color);
79  const arrowStyles = getArrowStyles(outOfBounds);
80  const isOutOfBounds = outOfBounds !== 'none';
81
82  return {
83    handle: css`
84      display: flex;
85      align-items: center;
86      position: absolute;
87      left: 0;
88      width: calc(100% - 9px);
89      height: 18px;
90      margin-top: -9px;
91      border-color: ${mainColor};
92      cursor: grab;
93      border-top-right-radius: ${theme.shape.borderRadius(1)};
94      border-bottom-right-radius: ${theme.shape.borderRadius(1)};
95      ${isOutOfBounds &&
96      css`
97        margin-top: 0;
98        border-radius: ${theme.shape.borderRadius(1)};
99      `}
100      background: ${mainColor};
101      font-size: ${theme.typography.bodySmall.fontSize};
102      &:before {
103        ${arrowStyles};
104      }
105    `,
106    handleText: css`
107      text-align: center;
108      width: 100%;
109      display: block;
110      text-overflow: ellipsis;
111      white-space: nowrap;
112      overflow: hidden;
113    `,
114  };
115};
116
117function getArrowStyles(outOfBounds: OutOfBounds) {
118  const inBounds = outOfBounds === 'none';
119
120  const triangle = (size: number) => css`
121    content: '';
122    position: absolute;
123
124    bottom: 0;
125    top: 0;
126    width: 0;
127    height: 0;
128    left: 0;
129
130    border-right-style: solid;
131    border-right-width: ${size}px;
132    border-right-color: inherit;
133    border-top: ${size}px solid transparent;
134    border-bottom: ${size}px solid transparent;
135  `;
136
137  if (inBounds) {
138    return css`
139      ${triangle(9)};
140      left: -9px;
141    `;
142  }
143
144  if (outOfBounds === 'top') {
145    return css`
146      ${triangle(5)};
147      left: calc(50% - 2.5px);
148      top: -7px;
149      transform: rotate(90deg);
150    `;
151  }
152
153  if (outOfBounds === 'bottom') {
154    return css`
155      ${triangle(5)};
156      left: calc(50% - 2.5px);
157      top: calc(100% - 2.5px);
158      transform: rotate(-90deg);
159    `;
160  }
161
162  return '';
163}
164