/* * Copyright 2017 Google Inc. All rights reserved. * Modifications copyright (c) Microsoft Corporation. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { msToString, useMeasure } from '@web/uiUtils'; import * as React from 'react'; import type { Boundaries } from '../geometry'; import { FilmStrip } from './filmStrip'; import type { FilmStripPreviewPoint } from './filmStrip'; import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import './timeline.css'; import type { Language } from '@isomorphic/locatorGenerators'; import type { Entry } from '@trace/har'; type TimelineBar = { action?: ActionTraceEventInContext; resource?: Entry; leftPosition: number; rightPosition: number; leftTime: number; rightTime: number; }; export const Timeline: React.FunctionComponent<{ model: MultiTraceModel | undefined, boundaries: Boundaries, onSelected: (action: ActionTraceEventInContext) => void, selectedTime: Boundaries | undefined, setSelectedTime: (time: Boundaries | undefined) => void, sdkLanguage: Language, }> = ({ model, boundaries, onSelected, selectedTime, setSelectedTime, sdkLanguage }) => { const [measure, ref] = useMeasure(); const [dragWindow, setDragWindow] = React.useState<{ startX: number, endX: number, pivot?: number, type: 'resize' | 'move' } | undefined>(); const [previewPoint, setPreviewPoint] = React.useState(); const { offsets, curtainLeft, curtainRight } = React.useMemo(() => { let activeWindow = selectedTime || boundaries; if (dragWindow && dragWindow.startX !== dragWindow.endX) { const time1 = positionToTime(measure.width, boundaries, dragWindow.startX); const time2 = positionToTime(measure.width, boundaries, dragWindow.endX); activeWindow = { minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) }; } const curtainLeft = timeToPosition(measure.width, boundaries, activeWindow.minimum); const maxRight = timeToPosition(measure.width, boundaries, boundaries.maximum); const curtainRight = maxRight - timeToPosition(measure.width, boundaries, activeWindow.maximum); return { offsets: calculateDividerOffsets(measure.width, boundaries), curtainLeft, curtainRight }; }, [selectedTime, boundaries, dragWindow, measure]); const bars = React.useMemo(() => { const bars: TimelineBar[] = []; for (const entry of model?.actions || []) { if (entry.class === 'Test') continue; bars.push({ action: entry, leftTime: entry.startTime, rightTime: entry.endTime || boundaries.maximum, leftPosition: timeToPosition(measure.width, boundaries, entry.startTime), rightPosition: timeToPosition(measure.width, boundaries, entry.endTime || boundaries.maximum), }); } for (const resource of model?.resources || []) { const startTime = resource._monotonicTime!; const endTime = resource._monotonicTime! + resource.time; bars.push({ resource, leftTime: startTime, rightTime: endTime, leftPosition: timeToPosition(measure.width, boundaries, startTime), rightPosition: timeToPosition(measure.width, boundaries, endTime), }); } return bars; }, [model, boundaries, measure]); const onMouseDown = React.useCallback((event: React.MouseEvent) => { setPreviewPoint(undefined); if (!ref.current) return; const x = event.clientX - ref.current.getBoundingClientRect().left; const time = positionToTime(measure.width, boundaries, x); const leftX = selectedTime ? timeToPosition(measure.width, boundaries, selectedTime.minimum) : 0; const rightX = selectedTime ? timeToPosition(measure.width, boundaries, selectedTime.maximum) : 0; if (selectedTime && Math.abs(x - leftX) < 10) { // Resize left. setDragWindow({ startX: rightX, endX: x, type: 'resize' }); } else if (selectedTime && Math.abs(x - rightX) < 10) { // Resize right. setDragWindow({ startX: leftX, endX: x, type: 'resize' }); } else if (selectedTime && time > selectedTime.minimum && time < selectedTime.maximum && event.clientY - ref.current.getBoundingClientRect().top < 20) { // Move window. setDragWindow({ startX: leftX, endX: rightX, pivot: x, type: 'move' }); } else { // Create new. setDragWindow({ startX: x, endX: x, type: 'resize' }); } }, [boundaries, measure, ref, selectedTime]); const onMouseMove = React.useCallback((event: React.MouseEvent) => { if (!ref.current) return; const x = event.clientX - ref.current.getBoundingClientRect().left; const time = positionToTime(measure.width, boundaries, x); const action = model?.actions.findLast(action => action.startTime <= time); if (!dragWindow) { setPreviewPoint({ x, clientY: event.clientY, action, sdkLanguage }); return; } if (!event.buttons) { setDragWindow(undefined); return; } // When moving window reveal action under cursor. if (action) onSelected(action); let newDragWindow = dragWindow; if (dragWindow.type === 'resize') { newDragWindow = { ...dragWindow, endX: x }; } else { const delta = x - dragWindow.pivot!; let startX = dragWindow.startX + delta; let endX = dragWindow.endX + delta; if (startX < 0) { startX = 0; endX = startX + (dragWindow.endX - dragWindow.startX); } if (endX > measure.width) { endX = measure.width; startX = endX - (dragWindow.endX - dragWindow.startX); } newDragWindow = { ...dragWindow, startX, endX, pivot: x }; } setDragWindow(newDragWindow); const time1 = positionToTime(measure.width, boundaries, newDragWindow.startX); const time2 = positionToTime(measure.width, boundaries, newDragWindow.endX); if (time1 !== time2) setSelectedTime({ minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) }); }, [boundaries, dragWindow, measure, model, onSelected, ref, sdkLanguage, setSelectedTime]); const onMouseUp = React.useCallback(() => { setPreviewPoint(undefined); if (!dragWindow) return; if (dragWindow.startX !== dragWindow.endX) { const time1 = positionToTime(measure.width, boundaries, dragWindow.startX); const time2 = positionToTime(measure.width, boundaries, dragWindow.endX); setSelectedTime({ minimum: Math.min(time1, time2), maximum: Math.max(time1, time2) }); } else { const time = positionToTime(measure.width, boundaries, dragWindow.startX); const action = model?.actions.findLast(action => action.startTime <= time); if (action) onSelected(action); // Include both, last action as well as the click position. if (selectedTime && (time < selectedTime.minimum || time > selectedTime.maximum)) { const minimum = action ? Math.max(Math.min(action.startTime, time), boundaries.minimum) : boundaries.minimum; const maximum = action ? Math.min(Math.max(action.endTime, time), boundaries.maximum) : boundaries.maximum; setSelectedTime({ minimum, maximum }); } } setDragWindow(undefined); }, [boundaries, dragWindow, measure, model, selectedTime, setSelectedTime, onSelected]); const onMouseLeave = React.useCallback(() => { setPreviewPoint(undefined); }, []); const onDoubleClick = React.useCallback(() => { setSelectedTime(undefined); }, [setSelectedTime]); return
{ offsets.map((offset, index) => { return
{msToString(offset.time - boundaries.minimum)}
; }) }
{
{ bars.map((bar, index) => { return
; }) }
}
; }; function calculateDividerOffsets(clientWidth: number, boundaries: Boundaries): { position: number, time: number }[] { const minimumGap = 64; let dividerCount = clientWidth / minimumGap; const boundarySpan = boundaries.maximum - boundaries.minimum; const pixelsPerMillisecond = clientWidth / boundarySpan; let sectionTime = boundarySpan / dividerCount; const logSectionTime = Math.ceil(Math.log(sectionTime) / Math.LN10); sectionTime = Math.pow(10, logSectionTime); if (sectionTime * pixelsPerMillisecond >= 5 * minimumGap) sectionTime = sectionTime / 5; if (sectionTime * pixelsPerMillisecond >= 2 * minimumGap) sectionTime = sectionTime / 2; const firstDividerTime = boundaries.minimum; let lastDividerTime = boundaries.maximum; lastDividerTime += minimumGap / pixelsPerMillisecond; dividerCount = Math.ceil((lastDividerTime - firstDividerTime) / sectionTime); if (!sectionTime) dividerCount = 0; const offsets = []; for (let i = 0; i < dividerCount; ++i) { const time = firstDividerTime + sectionTime * i; offsets.push({ position: timeToPosition(clientWidth, boundaries, time), time }); } return offsets; } function timeToPosition(clientWidth: number, boundaries: Boundaries, time: number): number { return (time - boundaries.minimum) / (boundaries.maximum - boundaries.minimum) * clientWidth; } function positionToTime(clientWidth: number, boundaries: Boundaries, x: number): number { return x / clientWidth * (boundaries.maximum - boundaries.minimum) + boundaries.minimum; } function barTop(bar: TimelineBar): number { return bar.resource ? 5 : 0; }