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