gridLines.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import {useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import {mergeRefs} from '@react-aria/utils';
  4. import moment from 'moment-timezone';
  5. import {updateDateTime} from 'sentry/actionCreators/pageFilters';
  6. import {DateTime} from 'sentry/components/dateTime';
  7. import {space} from 'sentry/styles/space';
  8. import useRouter from 'sentry/utils/useRouter';
  9. import {CronServiceIncidents} from './serviceIncidents';
  10. import {useTimelineCursor} from './timelineCursor';
  11. import {useTimelineZoom} from './timelineZoom';
  12. import type {TimeWindowConfig} from './types';
  13. interface Props {
  14. timeWindowConfig: TimeWindowConfig;
  15. /**
  16. * Enable zoom selection
  17. */
  18. allowZoom?: boolean;
  19. className?: string;
  20. /**
  21. * Enable the timeline cursor
  22. */
  23. showCursor?: boolean;
  24. /**
  25. * Render sentry service incidents as an overlay
  26. */
  27. showIncidents?: boolean;
  28. /**
  29. * Enabling causes the cursor tooltip to stick to the top of the viewport.
  30. */
  31. stickyCursor?: boolean;
  32. }
  33. interface TimeMarker {
  34. date: Date;
  35. /**
  36. * Props to pass to the DateTime component
  37. */
  38. dateTimeProps: TimeWindowConfig['dateTimeProps'];
  39. /**
  40. * The position in pixels of the tick
  41. */
  42. position: number;
  43. }
  44. /**
  45. * Aligns the given date to the start of a unit (minute, hour, day) based on
  46. * the minuteInterval size. This will align to the right side of the boundary
  47. *
  48. * 01:53:43 (10m interval) => 01:54:00
  49. * 01:32:00 (2hr interval) => 02:00:00
  50. */
  51. function alignDateToBoundary(date: moment.Moment, minuteInterval: number) {
  52. if (minuteInterval < 60) {
  53. return date.minute(date.minutes() - (date.minutes() % minuteInterval)).seconds(0);
  54. }
  55. if (minuteInterval < 60 * 24) {
  56. return date.startOf('hour');
  57. }
  58. return date.startOf('day');
  59. }
  60. function getTimeMarkersFromConfig(config: TimeWindowConfig) {
  61. const {start, end, elapsedMinutes, intervals, dateTimeProps, timelineWidth} = config;
  62. const {referenceMarkerInterval, minimumMarkerInterval, normalMarkerInterval} =
  63. intervals;
  64. const msPerPixel = (elapsedMinutes * 60 * 1000) / timelineWidth;
  65. // The first marker will always be the starting time. This always renders the
  66. // full date and time
  67. const markers: TimeMarker[] = [
  68. {
  69. date: start,
  70. position: 0,
  71. dateTimeProps: {timeZone: true},
  72. },
  73. ];
  74. // The mark after the first mark will be aligned to a boundary to make it
  75. // easier to understand the rest of the marks
  76. const currentMark = alignDateToBoundary(moment(start), normalMarkerInterval);
  77. // The first label is larger since we include the date, time, and timezone.
  78. while (currentMark.isBefore(moment(start).add(referenceMarkerInterval, 'minutes'))) {
  79. currentMark.add(normalMarkerInterval, 'minute');
  80. }
  81. // Generate time markers which represent location of grid lines/time labels.
  82. // Stop adding markers once there's no more room for more markers
  83. while (moment(currentMark).add(minimumMarkerInterval, 'minutes').isBefore(end)) {
  84. const position = (currentMark.valueOf() - start.valueOf()) / msPerPixel;
  85. markers.push({date: currentMark.toDate(), position, dateTimeProps});
  86. currentMark.add(normalMarkerInterval, 'minutes');
  87. }
  88. return markers;
  89. }
  90. export function GridLineLabels({timeWindowConfig, className}: Props) {
  91. const markers = getTimeMarkersFromConfig(timeWindowConfig);
  92. return (
  93. <LabelsContainer aria-hidden className={className}>
  94. {markers.map(({date, position, dateTimeProps}) => (
  95. <TimeLabelContainer key={date.getTime()} left={position}>
  96. <TimeLabel date={date} {...dateTimeProps} />
  97. </TimeLabelContainer>
  98. ))}
  99. </LabelsContainer>
  100. );
  101. }
  102. export function GridLineOverlay({
  103. timeWindowConfig,
  104. showCursor,
  105. showIncidents,
  106. stickyCursor,
  107. allowZoom,
  108. className,
  109. }: Props) {
  110. const router = useRouter();
  111. const {start, timelineWidth, dateLabelFormat} = timeWindowConfig;
  112. const msPerPixel = (timeWindowConfig.elapsedMinutes * 60 * 1000) / timelineWidth;
  113. const dateFromPosition = useCallback(
  114. (position: number) => moment(start.getTime() + msPerPixel * position),
  115. [msPerPixel, start]
  116. );
  117. const makeCursorLabel = useCallback(
  118. (position: number) => dateFromPosition(position).format(dateLabelFormat),
  119. [dateFromPosition, dateLabelFormat]
  120. );
  121. const handleZoom = useCallback(
  122. (startX: number, endX: number) =>
  123. updateDateTime(
  124. {
  125. start: dateFromPosition(startX).toDate(),
  126. end: dateFromPosition(endX).toDate(),
  127. },
  128. router
  129. ),
  130. [dateFromPosition, router]
  131. );
  132. const {
  133. selectionContainerRef,
  134. timelineSelector,
  135. isActive: selectionIsActive,
  136. } = useTimelineZoom<HTMLDivElement>({enabled: !!allowZoom, onSelect: handleZoom});
  137. const {cursorContainerRef, timelineCursor} = useTimelineCursor<HTMLDivElement>({
  138. enabled: showCursor && !selectionIsActive,
  139. sticky: stickyCursor,
  140. labelText: makeCursorLabel,
  141. });
  142. const overlayRef = mergeRefs(cursorContainerRef, selectionContainerRef);
  143. const markers = getTimeMarkersFromConfig(timeWindowConfig);
  144. // Skip first gridline, this will be represented as a border on the
  145. // LabelsContainer
  146. markers.shift();
  147. return (
  148. <Overlay aria-hidden ref={overlayRef} className={className}>
  149. {timelineCursor}
  150. {timelineSelector}
  151. {showIncidents && <CronServiceIncidents timeWindowConfig={timeWindowConfig} />}
  152. <GridLineContainer>
  153. {markers.map(({date, position}) => (
  154. <Gridline key={date.getTime()} left={position} />
  155. ))}
  156. </GridLineContainer>
  157. </Overlay>
  158. );
  159. }
  160. const Overlay = styled('div')`
  161. height: 100%;
  162. width: 100%;
  163. position: absolute;
  164. `;
  165. const GridLineContainer = styled('div')`
  166. position: relative;
  167. height: 100%;
  168. z-index: 1;
  169. pointer-events: none;
  170. `;
  171. const LabelsContainer = styled('div')`
  172. height: 50px;
  173. box-shadow: -1px 0 0 ${p => p.theme.translucentInnerBorder};
  174. position: relative;
  175. align-self: stretch;
  176. `;
  177. const Gridline = styled('div')<{left: number}>`
  178. position: absolute;
  179. left: ${p => p.left}px;
  180. border-left: 1px solid ${p => p.theme.translucentInnerBorder};
  181. height: 100%;
  182. `;
  183. const TimeLabelContainer = styled(Gridline)`
  184. display: flex;
  185. height: 100%;
  186. align-items: center;
  187. border-left: none;
  188. `;
  189. const TimeLabel = styled(DateTime)`
  190. font-variant-numeric: tabular-nums;
  191. font-size: ${p => p.theme.fontSizeSmall};
  192. color: ${p => p.theme.subText};
  193. margin-left: ${space(1)};
  194. `;