gridLines.tsx 6.1 KB

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