import {useCallback} from 'react'; import styled from '@emotion/styled'; import {mergeRefs} from '@react-aria/utils'; import moment from 'moment'; import {updateDateTime} from 'sentry/actionCreators/pageFilters'; import {DateTime} from 'sentry/components/dateTime'; import {space} from 'sentry/styles/space'; import useRouter from 'sentry/utils/useRouter'; import {useTimelineCursor} from './timelineCursor'; import {useTimelineZoom} from './timelineZoom'; import type {TimeWindowConfig} from './types'; interface Props { timeWindowConfig: TimeWindowConfig; /** * The size of the timeline */ width: number; /** * Enable zoom selection */ allowZoom?: boolean; className?: string; /** * Enable the timeline cursor */ showCursor?: boolean; /** * Enabling causes the cursor tooltip to stick to the top of the viewport. */ stickyCursor?: boolean; } interface TimeMarker { date: Date; /** * Props to pass to the DateTime component */ dateTimeProps: TimeWindowConfig['dateTimeProps']; /** * The position in pixels of the tick */ position: number; } /** * Aligns the given date to the start of a unit (minute, hour, day) based on * the minuteInterval size. This will align to the right side of the boundary * * 01:53:43 (10m interval) => 01:54:00 * 01:32:00 (2hr interval) => 02:00:00 */ function alignDateToBoundary(date: moment.Moment, minuteInterval: number) { if (minuteInterval < 60) { return date.minute(date.minutes() - (date.minutes() % minuteInterval)).seconds(0); } if (minuteInterval < 60 * 24) { return date.startOf('hour'); } return date.startOf('day'); } function getTimeMarkersFromConfig(config: TimeWindowConfig, width: number) { const {start, end, elapsedMinutes, intervals, dateTimeProps} = config; const {referenceMarkerInterval, minimumMarkerInterval, normalMarkerInterval} = intervals; const msPerPixel = (elapsedMinutes * 60 * 1000) / width; // The first marker will always be the starting time. This always renders the // full date and time const markers: TimeMarker[] = [ { date: start, position: 0, dateTimeProps: {timeZone: true}, }, ]; // The mark after the first mark will be aligned to a boundary to make it // easier to understand the rest of the marks const currentMark = alignDateToBoundary(moment(start), normalMarkerInterval); // The first label is larger since we include the date, time, and timezone. while (currentMark.isBefore(moment(start).add(referenceMarkerInterval, 'minutes'))) { currentMark.add(normalMarkerInterval, 'minute'); } // Generate time markers which represent location of grid lines/time labels. // Stop adding markers once there's no more room for more markers while (moment(currentMark).add(minimumMarkerInterval, 'minutes').isBefore(end)) { const position = (currentMark.valueOf() - start.valueOf()) / msPerPixel; markers.push({date: currentMark.toDate(), position, dateTimeProps}); currentMark.add(normalMarkerInterval, 'minutes'); } return markers; } export function GridLineLabels({width, timeWindowConfig, className}: Props) { const markers = getTimeMarkersFromConfig(timeWindowConfig, width); return ( {markers.map(({date, position, dateTimeProps}) => ( ))} ); } export function GridLineOverlay({ width, timeWindowConfig, showCursor, stickyCursor, allowZoom, className, }: Props) { const router = useRouter(); const {start, dateLabelFormat} = timeWindowConfig; const msPerPixel = (timeWindowConfig.elapsedMinutes * 60 * 1000) / width; const dateFromPosition = useCallback( (position: number) => moment(start.getTime() + msPerPixel * position), [msPerPixel, start] ); const makeCursorLabel = useCallback( (position: number) => dateFromPosition(position).format(dateLabelFormat), [dateFromPosition, dateLabelFormat] ); const handleZoom = useCallback( (startX: number, endX: number) => updateDateTime( { start: dateFromPosition(startX).toDate(), end: dateFromPosition(endX).toDate(), }, router ), [dateFromPosition, router] ); const { selectionContainerRef, timelineSelector, isActive: selectionIsActive, } = useTimelineZoom({enabled: !!allowZoom, onSelect: handleZoom}); const {cursorContainerRef, timelineCursor} = useTimelineCursor({ enabled: showCursor && !selectionIsActive, sticky: stickyCursor, labelText: makeCursorLabel, }); const overlayRef = mergeRefs(cursorContainerRef, selectionContainerRef); const markers = getTimeMarkersFromConfig(timeWindowConfig, width); // Skip first gridline, this will be represented as a border on the // LabelsContainer markers.shift(); return ( {timelineCursor} {timelineSelector} {markers.map(({date, position}) => ( ))} ); } const Overlay = styled('div')` height: 100%; width: 100%; position: absolute; pointer-events: none; `; const GridLineContainer = styled('div')` position: relative; height: 100%; z-index: 1; `; const LabelsContainer = styled('div')` height: 50px; box-shadow: -1px 0 0 ${p => p.theme.translucentInnerBorder}; position: relative; align-self: stretch; `; const Gridline = styled('div')<{left: number}>` position: absolute; left: ${p => p.left}px; border-left: 1px solid ${p => p.theme.translucentInnerBorder}; height: 100%; `; const TimeLabelContainer = styled(Gridline)` display: flex; height: 100%; align-items: center; border-left: none; `; const TimeLabel = styled(DateTime)` font-variant-numeric: tabular-nums; font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.subText}; margin-left: ${space(1)}; `;