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