diff --git a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.css b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.css index 5d64fa7081..36b11e9b13 100644 --- a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.css +++ b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.css @@ -52,6 +52,15 @@ limitations under the License. fill: #f44; } +/* Styles for greying out the area outside the marker points */ +.ViewingLayer--area { + fill: #fff; +} + +.ViewingLayer--area.isActive { + fill: rgba(214, 214, 214, 0.5); +} + .ViewingLayer--fullOverlay { bottom: 0; cursor: col-resize; @@ -73,3 +82,30 @@ limitations under the License. .ViewingLayer:hover > .ViewingLayer--resetZoom { display: unset; } + +/* Scroll Bar Styles */ +.ViewingLayer--scrollBar { + position: relative; + overflow-x: hidden; + overflow-y: hidden; + height: 20px; + visibility: hidden; +} + +.ViewingLayer--scrollBar:hover { + visibility: visible; + overflow-x: auto; +} + +.ViewingLayer--scrollThumb { + position: absolute; + height: 100%; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +.ViewingLayer--scrollThumb:hover { + background-color: rgba(0, 0, 0, 0.4); +} \ No newline at end of file diff --git a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js index 7359272442..a7aa22dbdf 100644 --- a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js +++ b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.test.js @@ -29,7 +29,7 @@ function getViewRange(viewStart, viewEnd) { }; } -describe('', () => { +describe('', () => { polyfillAnimationFrame(window); let props; @@ -255,67 +255,87 @@ describe('', () => { }); }); }); + }); - describe('.ViewingLayer--resetZoom', () => { - it('should not render .ViewingLayer--resetZoom if props.viewRange.time.current = [0,1]', () => { - expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(0); - wrapper.setProps({ viewRange: { time: { current: [0, 1] } } }); - expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(0); - }); - - it('should render ViewingLayer--resetZoom if props.viewRange.time.current[0] !== 0', () => { - // If the test fails on the following expect statement, this may be a false negative - expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(0); - wrapper.setProps({ viewRange: { time: { current: [0.1, 1] } } }); - expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(1); - }); + describe('Scroll bar and thumb behavior', () => { + it('shows scroll bar when view range is not full', () => { + wrapper.setProps({ viewRange: getViewRange(0.2, 0.8) }); + wrapper.setState({ showScrollBar: true }); + expect(wrapper.find('.ViewingLayer--scrollBar').prop('style').visibility).toBe('visible'); + }); - it('should render ViewingLayer--resetZoom if props.viewRange.time.current[1] !== 1', () => { - // If the test fails on the following expect statement, this may be a false negative - expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(0); - wrapper.setProps({ viewRange: { time: { current: [0, 0.9] } } }); - expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(1); - }); + it('hides scroll bar when view range is full', () => { + wrapper.setProps({ viewRange: getViewRange(0, 1) }); + wrapper.setState({ showScrollBar: true }); + const scrollBar = wrapper.find('.ViewingLayer--scrollBar'); + expect(scrollBar.prop('style').visibility).toBe('hidden'); + }); - it('should call props.updateViewRangeTime when clicked', () => { - wrapper.setProps({ viewRange: { time: { current: [0.1, 0.9] } } }); - const resetZoomButton = wrapper.find('.ViewingLayer--resetZoom'); - // If the test fails on the following expect statement, this may be a false negative caused - // by a regression to rendering. - expect(resetZoomButton.length).toBe(1); + it('updates scroll thumb position on view range change', () => { + const instance = wrapper.instance(); + instance._scrollBar = { clientWidth: 200 }; + instance._scrollThumb = { style: {} }; + wrapper.setProps({ viewRange: getViewRange(0.2, 0.8) }); + instance._updateScrollThumb(); + expect(instance._scrollThumb.style.left).toBe('40px'); + expect(parseFloat(instance._scrollThumb.style.width)).toBeCloseTo(120, 0); + }); - resetZoomButton.simulate('click'); - expect(props.updateViewRangeTime).lastCalledWith(0, 1); - }); + it('handles scroll event', () => { + const mockEvent = { + currentTarget: { + scrollLeft: 20, + scrollWidth: 200, + clientWidth: 100, + }, + }; + const instance = wrapper.instance(); + instance._scrollBar = mockEvent.currentTarget; + instance._handleScroll(mockEvent); + expect(props.updateViewRangeTime).toHaveBeenCalledWith(0.1, 0.6, 'scroll'); }); - }); - it('renders a ', () => { - expect(wrapper.find(GraphTicks).length).toBe(1); - }); + it('handles thumb mouse down event', () => { + const instance = wrapper.instance(); + const mockEvent = { clientX: 100, preventDefault: jest.fn() }; + instance._onThumbMouseDown(mockEvent); + expect(instance._isDraggingThumb).toBe(true); + expect(instance._startX).toBe(100); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); - it('renders a filtering box if leftBound exists', () => { - const _props = { ...props, viewRange: getViewRange(0.2, 1) }; - wrapper = shallow(); + it('handles thumb mouse move event', () => { + const instance = wrapper.instance(); + instance._isDraggingThumb = true; + instance._startX = 100; + instance._startLeft = 50; + instance._scrollBar = { clientWidth: 200 }; + instance._scrollThumb = { clientWidth: 100, style: {} }; + const mockEvent = { clientX: 120 }; + instance._onThumbMouseMove(mockEvent); + expect(props.updateViewRangeTime).toHaveBeenCalledWith(0.35, 0.85, 'scroll'); + }); - const leftBox = wrapper.find('.ViewingLayer--inactive'); - expect(leftBox.length).toBe(1); - const width = Number(leftBox.prop('width').slice(0, -1)); - const x = leftBox.prop('x'); - expect(Math.round(width)).toBe(20); - expect(x).toBe(0); + it('handles thumb mouse up event', () => { + const instance = wrapper.instance(); + instance._isDraggingThumb = true; + instance._onThumbMouseUp(); + expect(instance._isDraggingThumb).toBe(false); + }); }); - it('renders a filtering box if rightBound exists', () => { - const _props = { ...props, viewRange: getViewRange(0, 0.8) }; - wrapper = shallow(); + it('renders inactive areas correctly', () => { + wrapper.setProps({ viewRange: getViewRange(0.2, 0.8) }); + const inactiveAreas = wrapper.find('.ViewingLayer--inactive'); + expect(inactiveAreas).toHaveLength(2); + expect(inactiveAreas.at(0).prop('x')).toBe('0'); + expect(parseFloat(inactiveAreas.at(0).prop('width'))).toBeCloseTo(20, 1); + expect(inactiveAreas.at(1).prop('x')).toBe('80.000000%'); + expect(parseFloat(inactiveAreas.at(1).prop('width'))).toBeCloseTo(20, 1); + }); - const rightBox = wrapper.find('.ViewingLayer--inactive'); - expect(rightBox.length).toBe(1); - const width = Number(rightBox.prop('width').slice(0, -1)); - const x = Number(rightBox.prop('x').slice(0, -1)); - expect(Math.round(width)).toBe(20); - expect(x).toBe(80); + it('renders a ', () => { + expect(wrapper.find(GraphTicks).length).toBe(1); }); it('renders handles for the timeRangeFilter', () => { @@ -325,4 +345,32 @@ describe('', () => { scrubber = ; expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy(); }); -}); + + describe('.ViewingLayer--resetZoom', () => { + it('should not render if props.viewRange.time.current = [0,1]', () => { + expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(0); + wrapper.setProps({ viewRange: { time: { current: [0, 1] } } }); + expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(0); + }); + + it('should render if props.viewRange.time.current[0] !== 0', () => { + wrapper.setProps({ viewRange: { time: { current: [0.1, 1] } } }); + wrapper.setState({ showScrollBar: true }); + expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(1); + }); + + it('should render if props.viewRange.time.current[1] !== 1', () => { + wrapper.setProps({ viewRange: { time: { current: [0, 0.9] } } }); + wrapper.setState({ showScrollBar: true }); + expect(wrapper.find('.ViewingLayer--resetZoom').length).toBe(1); + }); + + it('should call props.updateViewRangeTime when clicked', () => { + wrapper.setProps({ viewRange: { time: { current: [0.1, 0.9] } } }); + wrapper.setState({ showScrollBar: true }); + const resetZoomButton = wrapper.find('.ViewingLayer--resetZoom'); + resetZoomButton.simulate('click'); + expect(props.updateViewRangeTime).lastCalledWith(0, 1); + }); + }); +}); \ No newline at end of file diff --git a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx index 6535b22522..bed5bd476d 100644 --- a/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx +++ b/packages/jaeger-ui/src/components/TracePage/TracePageHeader/SpanGraph/ViewingLayer.tsx @@ -28,6 +28,7 @@ import DraggableManager, { import './ViewingLayer.css'; +// Define prop types for the ViewingLayer component type ViewingLayerProps = { height: number; numTicks: number; @@ -36,38 +37,19 @@ type ViewingLayerProps = { viewRange: IViewRange; }; +// Define state types for the ViewingLayer component type ViewingLayerState = { - /** - * Cursor line should not be drawn when the mouse is over the scrubber handle. - */ preventCursorLine: boolean; }; -/** - * Designate the tags for the different dragging managers. Exported for tests. - */ +// Define drag types for different interactions export const dragTypes = { - /** - * Tag for dragging the right scrubber, e.g. end of the current view range. - */ SHIFT_END: 'SHIFT_END', - /** - * Tag for dragging the left scrubber, e.g. start of the current view range. - */ SHIFT_START: 'SHIFT_START', - /** - * Tag for dragging a new view range. - */ REFRAME: 'REFRAME', }; -/** - * Returns the layout information for drawing the view-range differential, e.g. - * show what will change when the mouse is released. Basically, this is the - * difference from the start of the drag to the current position. - * - * @returns {{ x: string, width: string, leadginX: string }} - */ +// Helper function to calculate the next view layout based on start and position function getNextViewLayout(start: number, position: number) { const [left, right] = start < position ? [start, position] : [position, start]; return { @@ -77,36 +59,27 @@ function getNextViewLayout(start: number, position: number) { }; } -/** - * `ViewingLayer` is rendered on top of the Canvas rendering of the minimap and - * handles showing the current view range and handles mouse UX for modifying it. - */ +// Main ViewingLayer component export default class ViewingLayer extends React.PureComponent { - state: ViewingLayerState; + state: ViewingLayerState = { + preventCursorLine: false, + }; + // Refs and instance variables _root: Element | TNil; - - /** - * `_draggerReframe` handles clicking and dragging on the `ViewingLayer` to - * redefined the view range. - */ + _scrollBar: HTMLDivElement | TNil; + _scrollThumb: HTMLDivElement | TNil; _draggerReframe: DraggableManager; - - /** - * `_draggerStart` handles dragging the left scrubber to adjust the start of - * the view range. - */ _draggerStart: DraggableManager; - - /** - * `_draggerEnd` handles dragging the right scrubber to adjust the end of - * the view range. - */ _draggerEnd: DraggableManager; + _isDraggingThumb: boolean = false; + _startX: number = 0; + _startLeft: number = 0; constructor(props: ViewingLayerProps) { super(props); + // Initialize draggable managers for different interactions this._draggerReframe = new DraggableManager({ getBounds: this._getDraggingBounds, onDragEnd: this._handleReframeDragEnd, @@ -138,24 +111,107 @@ export default class ViewingLayer extends React.PureComponent { this._root = elm; }; + _setScrollBar = (elm: HTMLDivElement | TNil) => { + this._scrollBar = elm; + }; + + _setScrollThumb = (elm: HTMLDivElement | TNil) => { + this._scrollThumb = elm; + }; + + // Scroll thumb event listeners + _addScrollThumbListeners() { + if (this._scrollThumb) { + this._scrollThumb.addEventListener('mousedown', this._onThumbMouseDown); + } + } + + _removeScrollThumbListeners() { + if (this._scrollThumb) { + this._scrollThumb.removeEventListener('mousedown', this._onThumbMouseDown); + } + document.removeEventListener('mousemove', this._onThumbMouseMove); + document.removeEventListener('mouseup', this._onThumbMouseUp); + } + + // Scroll thumb drag handlers + _onThumbMouseDown = (e: MouseEvent) => { + e.preventDefault(); + this._isDraggingThumb = true; + this._startX = e.clientX; + this._startLeft = this._scrollThumb ? this._scrollThumb.offsetLeft : 0; + + document.addEventListener('mousemove', this._onThumbMouseMove); + document.addEventListener('mouseup', this._onThumbMouseUp); + }; + + _onThumbMouseMove = (e: MouseEvent) => { + if (!this._isDraggingThumb || !this._scrollThumb || !this._scrollBar) return; + + const deltaX = e.clientX - this._startX; + const newLeft = Math.min( + Math.max(0, this._startLeft + deltaX), + this._scrollBar.clientWidth - this._scrollThumb.clientWidth + ); + + const viewStart = newLeft / this._scrollBar.clientWidth; + const viewEnd = viewStart + this._scrollThumb.clientWidth / this._scrollBar.clientWidth; + + this._scrollThumb.style.left = `${newLeft}px`; + + this.props.updateViewRangeTime(viewStart, viewEnd, 'scroll'); + }; + + _onThumbMouseUp = () => { + this._isDraggingThumb = false; + document.removeEventListener('mousemove', this._onThumbMouseMove); + document.removeEventListener('mouseup', this._onThumbMouseUp); + }; + + // Update scroll thumb position and size + _updateScrollThumb = () => { + if (this._scrollBar && this._scrollThumb) { + const { current } = this.props.viewRange.time; + const [viewStart, viewEnd] = current; + const viewWidth = viewEnd - viewStart; + + this._scrollThumb.style.left = `${viewStart * this._scrollBar.clientWidth}px`; + this._scrollThumb.style.width = `${viewWidth * this._scrollBar.clientWidth}px`; + } + }; + + // Get dragging bounds for DraggableManager _getDraggingBounds = (tag: string | TNil): DraggableBounds => { if (!this._root) { - throw new Error('invalid state'); + throw new Error('Invalid state: root element is null'); } const { left: clientXLeft, width } = this._root.getBoundingClientRect(); const [viewStart, viewEnd] = this.props.viewRange.time.current; @@ -169,6 +225,7 @@ export default class ViewingLayer extends React.PureComponent { this.props.updateNextViewRangeTime({ cursor: value }); }; @@ -193,6 +250,7 @@ export default class ViewingLayer extends React.PureComponent { const preventCursorLine = type === EUpdateTypes.MouseEnter; this.setState({ preventCursorLine }); @@ -217,27 +275,29 @@ export default class ViewingLayer extends React.PureComponent) => { + if (!this._scrollBar) return; + const { scrollLeft, scrollWidth, clientWidth } = e.currentTarget; + const viewStart = scrollLeft / scrollWidth; + const viewEnd = (scrollLeft + clientWidth) / scrollWidth; + + this.props.updateViewRangeTime(viewStart, viewEnd, 'scroll'); + }; + + // Reset time zoom handler _resetTimeZoomClickHandler = () => { this.props.updateViewRangeTime(0, 1); }; - /** - * Renders the difference between where the drag started and the current - * position, e.g. the red or blue highlight. - * - * @returns React.Node[] - */ + // Generate markers for drag operations _getMarkers(from: number, to: number, isShift: boolean) { const layout = getNextViewLayout(from, to); const cls = cx({ @@ -264,28 +324,53 @@ export default class ViewingLayer extends React.PureComponent 0) { + rects.push( + + ); + } + if (viewEnd < 1) { + rects.push( + + ); + } + return rects; + } + render() { const { height, viewRange, numTicks } = this.props; const { preventCursorLine } = this.state; const { current, cursor, shiftStart, shiftEnd, reframe } = viewRange.time; - const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null; const [viewStart, viewEnd] = current; - let leftInactive = 0; - if (viewStart) { - leftInactive = viewStart * 100; - } - let rightInactive = 100; - if (viewEnd) { - rightInactive = 100 - viewEnd * 100; - } + const haveNextTimeRange = shiftStart != null || shiftEnd != null || reframe != null; + + const showScrollBar = viewStart > 0 || viewEnd < 1; + const containerHeight = showScrollBar ? height + 20 : height; + let cursorPosition: string | undefined; if (!haveNextTimeRange && cursor != null && !preventCursorLine) { cursorPosition = `${cursor * 100}%`; } return ( -
- {(viewStart !== 0 || viewEnd !== 1) && ( +
+ {showScrollBar && (