1/* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import {CdkDragEnd, CdkDragMove, CdkDragStart} from '@angular/cdk/drag-drop'; 18import { 19 ChangeDetectorRef, 20 Component, 21 ElementRef, 22 EventEmitter, 23 HostListener, 24 Inject, 25 Input, 26 Output, 27 SimpleChanges, 28 ViewChild, 29} from '@angular/core'; 30import {Color} from 'app/colors'; 31import {assertDefined} from 'common/assert_utils'; 32import {Point} from 'common/geometry/point'; 33import {TimeRange, Timestamp} from 'common/time/time'; 34import {ComponentTimestampConverter} from 'common/time/timestamp_converter'; 35import {TracePosition} from 'trace/trace_position'; 36import {Transformer} from './transformer'; 37 38@Component({ 39 selector: 'slider', 40 template: ` 41 <div id="timeline-slider-box" #sliderBox> 42 <div class="background line"></div> 43 <div 44 class="slider" 45 cdkDragLockAxis="x" 46 cdkDragBoundary="#timeline-slider-box" 47 cdkDrag 48 (cdkDragMoved)="onSliderMove($event)" 49 (cdkDragStarted)="onSlideStart($event)" 50 (cdkDragEnded)="onSlideEnd($event)" 51 [cdkDragFreeDragPosition]="dragPosition" 52 [style]="{width: sliderWidth + 'px'}"> 53 <div class="left cropper" (mousedown)="startMoveLeft($event)"></div> 54 <div class="handle" cdkDragHandle></div> 55 <div class="right cropper" (mousedown)="startMoveRight($event)"></div> 56 </div> 57 <div class="cursor" [style]="{left: cursorOffset + 'px'}"></div> 58 </div> 59 `, 60 styles: [ 61 ` 62 #timeline-slider-box { 63 position: relative; 64 margin-bottom: 5px; 65 } 66 67 #timeline-slider-box, 68 .slider { 69 height: 10px; 70 } 71 72 .line { 73 height: 3px; 74 position: absolute; 75 margin: auto; 76 top: 0; 77 bottom: 0; 78 margin: auto 0; 79 } 80 81 .background.line { 82 width: 100%; 83 background: ${Color.GUIDE_BAR}; 84 } 85 86 .selection.line { 87 background: var(--slider-border-color); 88 } 89 90 .slider { 91 display: flex; 92 justify-content: space-between; 93 cursor: grab; 94 position: absolute; 95 } 96 97 .handle { 98 flex-grow: 1; 99 background: var(--slider-background-color); 100 cursor: grab; 101 } 102 103 .cropper { 104 width: 5px; 105 background: var(--slider-border-color); 106 } 107 108 .cropper.left, 109 .cropper.right { 110 cursor: ew-resize; 111 } 112 113 .cursor { 114 width: 2px; 115 height: 100%; 116 position: absolute; 117 pointer-events: none; 118 background: ${Color.ACTIVE_POINTER}; 119 } 120 `, 121 ], 122}) 123export class SliderComponent { 124 @Input() fullRange: TimeRange | undefined; 125 @Input() zoomRange: TimeRange | undefined; 126 @Input() currentPosition: TracePosition | undefined; 127 @Input() timestampConverter: ComponentTimestampConverter | undefined; 128 129 @Output() readonly onZoomChanged = new EventEmitter<TimeRange>(); 130 131 dragging = false; 132 sliderWidth = 0; 133 dragPosition: Point = {x: 0, y: 0}; 134 viewInitialized = false; 135 cursorOffset = 0; 136 137 @ViewChild('sliderBox', {static: false}) sliderBox!: ElementRef; 138 139 constructor(@Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef) {} 140 141 ngOnChanges(changes: SimpleChanges) { 142 if (changes['zoomRange'] !== undefined && !this.dragging) { 143 const zoomRange = changes['zoomRange'].currentValue as TimeRange; 144 this.syncDragPositionTo(zoomRange); 145 } 146 147 if (changes['currentPosition']) { 148 const currentPosition = changes['currentPosition'] 149 .currentValue as TracePosition; 150 this.syncCursosPositionTo(currentPosition.timestamp); 151 } 152 } 153 154 syncDragPositionTo(zoomRange: TimeRange) { 155 this.sliderWidth = this.computeSliderWidth(); 156 const middleOfZoomRange = zoomRange.from.add( 157 zoomRange.to.minus(zoomRange.from.getValueNs()).div(2n).getValueNs(), 158 ); 159 160 this.dragPosition = { 161 // Calculation to account for there being a min width of the slider 162 x: 163 this.getTransformer().transform(middleOfZoomRange) - 164 this.sliderWidth / 2, 165 y: 0, 166 }; 167 } 168 169 syncCursosPositionTo(timestamp: Timestamp) { 170 this.cursorOffset = this.getTransformer().transform(timestamp); 171 } 172 173 getTransformer(): Transformer { 174 const width = this.viewInitialized 175 ? this.sliderBox.nativeElement.offsetWidth 176 : 0; 177 return new Transformer( 178 assertDefined(this.fullRange), 179 {from: 0, to: width}, 180 assertDefined(this.timestampConverter), 181 ); 182 } 183 184 ngAfterViewInit(): void { 185 this.viewInitialized = true; 186 } 187 188 ngAfterViewChecked() { 189 assertDefined(this.fullRange); 190 const zoomRange = assertDefined(this.zoomRange); 191 this.syncDragPositionTo(zoomRange); 192 this.cdr.detectChanges(); 193 } 194 195 @HostListener('window:resize', ['$event']) 196 onResize(event: Event) { 197 this.syncDragPositionTo(assertDefined(this.zoomRange)); 198 this.syncCursosPositionTo(assertDefined(this.currentPosition).timestamp); 199 } 200 201 computeSliderWidth() { 202 const transformer = this.getTransformer(); 203 let width = 204 transformer.transform(assertDefined(this.zoomRange).to) - 205 transformer.transform(assertDefined(this.zoomRange).from); 206 if (width < MIN_SLIDER_WIDTH) { 207 width = MIN_SLIDER_WIDTH; 208 } 209 210 return width; 211 } 212 213 slideStartX: number | undefined = undefined; 214 onSlideStart(e: CdkDragStart) { 215 this.dragging = true; 216 this.slideStartX = e.source.freeDragPosition.x; 217 document.body.classList.add('inheritCursors'); 218 document.body.style.cursor = 'grabbing'; 219 } 220 221 onSlideEnd(e: CdkDragEnd) { 222 this.dragging = false; 223 this.slideStartX = undefined; 224 this.syncDragPositionTo(assertDefined(this.zoomRange)); 225 document.body.classList.remove('inheritCursors'); 226 document.body.style.cursor = 'unset'; 227 } 228 229 onSliderMove(e: CdkDragMove) { 230 const zoomRange = assertDefined(this.zoomRange); 231 let newX = assertDefined(this.slideStartX) + e.distance.x; 232 if (newX < 0) { 233 newX = 0; 234 } 235 236 // Calculation to adjust for min width slider 237 const from = this.getTransformer() 238 .untransform(newX + this.sliderWidth / 2) 239 .minus( 240 zoomRange.to.minus(zoomRange.from.getValueNs()).div(2n).getValueNs(), 241 ); 242 243 const to = assertDefined(this.timestampConverter).makeTimestampFromNs( 244 from.getValueNs() + 245 (assertDefined(this.zoomRange).to.getValueNs() - 246 assertDefined(this.zoomRange).from.getValueNs()), 247 ); 248 249 this.onZoomChanged.emit(new TimeRange(from, to)); 250 } 251 252 startMoveLeft(e: MouseEvent) { 253 e.preventDefault(); 254 255 const startPos = e.pageX; 256 const startOffset = this.getTransformer().transform( 257 assertDefined(this.zoomRange).from, 258 ); 259 260 const listener = (event: MouseEvent) => { 261 const movedX = event.pageX - startPos; 262 let from = this.getTransformer().untransform(startOffset + movedX); 263 if (from.getValueNs() < assertDefined(this.fullRange).from.getValueNs()) { 264 from = assertDefined(this.fullRange).from; 265 } 266 if (from.getValueNs() > assertDefined(this.zoomRange).to.getValueNs()) { 267 from = assertDefined(this.zoomRange).to; 268 } 269 const to = assertDefined(this.zoomRange).to; 270 271 this.onZoomChanged.emit(new TimeRange(from, to)); 272 }; 273 addEventListener('mousemove', listener); 274 275 const mouseUpListener = () => { 276 removeEventListener('mousemove', listener); 277 removeEventListener('mouseup', mouseUpListener); 278 }; 279 addEventListener('mouseup', mouseUpListener); 280 } 281 282 startMoveRight(e: MouseEvent) { 283 e.preventDefault(); 284 285 const startPos = e.pageX; 286 const startOffset = this.getTransformer().transform( 287 assertDefined(this.zoomRange).to, 288 ); 289 290 const listener = (event: MouseEvent) => { 291 const movedX = event.pageX - startPos; 292 const from = assertDefined(this.zoomRange).from; 293 let to = this.getTransformer().untransform(startOffset + movedX); 294 if (to.getValueNs() > assertDefined(this.fullRange).to.getValueNs()) { 295 to = assertDefined(this.fullRange).to; 296 } 297 if (to.getValueNs() < assertDefined(this.zoomRange).from.getValueNs()) { 298 to = assertDefined(this.zoomRange).from; 299 } 300 301 this.onZoomChanged.emit(new TimeRange(from, to)); 302 }; 303 addEventListener('mousemove', listener); 304 305 const mouseUpListener = () => { 306 removeEventListener('mousemove', listener); 307 removeEventListener('mouseup', mouseUpListener); 308 }; 309 addEventListener('mouseup', mouseUpListener); 310 } 311} 312 313export const MIN_SLIDER_WIDTH = 30; 314