• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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