/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Inject,
Input,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import {Color} from 'app/colors';
import {assertDefined} from 'common/assert_utils';
import {Point} from 'common/geometry_types';
import {TimeRange, Timestamp} from 'common/time';
import {ComponentTimestampConverter} from 'common/timestamp_converter';
import {TracePosition} from 'trace/trace_position';
import {Transformer} from './transformer';
@Component({
selector: 'slider',
template: `
`,
styles: [
`
#timeline-slider-box {
position: relative;
margin-bottom: 5px;
}
#timeline-slider-box,
.slider {
height: 10px;
}
.line {
height: 3px;
position: absolute;
margin: auto;
top: 0;
bottom: 0;
margin: auto 0;
}
.background.line {
width: 100%;
background: ${Color.GUIDE_BAR};
}
.selection.line {
background: var(--slider-border-color);
}
.slider {
display: flex;
justify-content: space-between;
cursor: grab;
position: absolute;
}
.handle {
flex-grow: 1;
background: var(--slider-background-color);
cursor: grab;
}
.cropper {
width: 5px;
background: var(--slider-border-color);
}
.cropper.left,
.cropper.right {
cursor: ew-resize;
}
.cursor {
width: 2px;
height: 100%;
position: absolute;
pointer-events: none;
background: ${Color.ACTIVE_POINTER};
}
`,
],
})
export class SliderComponent {
@Input() fullRange: TimeRange | undefined;
@Input() zoomRange: TimeRange | undefined;
@Input() currentPosition: TracePosition | undefined;
@Input() timestampConverter: ComponentTimestampConverter | undefined;
@Output() readonly onZoomChanged = new EventEmitter();
dragging = false;
sliderWidth = 0;
dragPosition: Point = {x: 0, y: 0};
viewInitialized = false;
cursorOffset = 0;
@ViewChild('sliderBox', {static: false}) sliderBox!: ElementRef;
constructor(@Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef) {}
ngOnChanges(changes: SimpleChanges) {
if (changes['zoomRange'] !== undefined && !this.dragging) {
const zoomRange = changes['zoomRange'].currentValue as TimeRange;
this.syncDragPositionTo(zoomRange);
}
if (changes['currentPosition']) {
const currentPosition = changes['currentPosition']
.currentValue as TracePosition;
this.syncCursosPositionTo(currentPosition.timestamp);
}
}
syncDragPositionTo(zoomRange: TimeRange) {
this.sliderWidth = this.computeSliderWidth();
const middleOfZoomRange = zoomRange.from.add(
zoomRange.to.minus(zoomRange.from.getValueNs()).div(2n).getValueNs(),
);
this.dragPosition = {
// Calculation to account for there being a min width of the slider
x:
this.getTransformer().transform(middleOfZoomRange) -
this.sliderWidth / 2,
y: 0,
};
}
syncCursosPositionTo(timestamp: Timestamp) {
this.cursorOffset = this.getTransformer().transform(timestamp);
}
getTransformer(): Transformer {
const width = this.viewInitialized
? this.sliderBox.nativeElement.offsetWidth
: 0;
return new Transformer(
assertDefined(this.fullRange),
{from: 0, to: width},
assertDefined(this.timestampConverter),
);
}
ngAfterViewInit(): void {
this.viewInitialized = true;
}
ngAfterViewChecked() {
assertDefined(this.fullRange);
const zoomRange = assertDefined(this.zoomRange);
this.syncDragPositionTo(zoomRange);
this.cdr.detectChanges();
}
@HostListener('window:resize', ['$event'])
onResize(event: Event) {
this.syncDragPositionTo(assertDefined(this.zoomRange));
this.syncCursosPositionTo(assertDefined(this.currentPosition).timestamp);
}
computeSliderWidth() {
const transformer = this.getTransformer();
let width =
transformer.transform(assertDefined(this.zoomRange).to) -
transformer.transform(assertDefined(this.zoomRange).from);
if (width < MIN_SLIDER_WIDTH) {
width = MIN_SLIDER_WIDTH;
}
return width;
}
slideStartX: number | undefined = undefined;
onSlideStart(e: any) {
this.dragging = true;
this.slideStartX = e.source.freeDragPosition.x;
document.body.classList.add('inheritCursors');
document.body.style.cursor = 'grabbing';
}
onSlideEnd(e: any) {
this.dragging = false;
this.slideStartX = undefined;
this.syncDragPositionTo(assertDefined(this.zoomRange));
document.body.classList.remove('inheritCursors');
document.body.style.cursor = 'unset';
}
onSliderMove(e: any) {
const zoomRange = assertDefined(this.zoomRange);
let newX = this.slideStartX + e.distance.x;
if (newX < 0) {
newX = 0;
}
// Calculation to adjust for min width slider
const from = this.getTransformer()
.untransform(newX + this.sliderWidth / 2)
.minus(
zoomRange.to.minus(zoomRange.from.getValueNs()).div(2n).getValueNs(),
);
const to = assertDefined(this.timestampConverter).makeTimestampFromNs(
from.getValueNs() +
(assertDefined(this.zoomRange).to.getValueNs() -
assertDefined(this.zoomRange).from.getValueNs()),
);
this.onZoomChanged.emit(new TimeRange(from, to));
}
startMoveLeft(e: any) {
e.preventDefault();
const startPos = e.pageX;
const startOffset = this.getTransformer().transform(
assertDefined(this.zoomRange).from,
);
const listener = (event: any) => {
const movedX = event.pageX - startPos;
let from = this.getTransformer().untransform(startOffset + movedX);
if (from.getValueNs() < assertDefined(this.fullRange).from.getValueNs()) {
from = assertDefined(this.fullRange).from;
}
if (from.getValueNs() > assertDefined(this.zoomRange).to.getValueNs()) {
from = assertDefined(this.zoomRange).to;
}
const to = assertDefined(this.zoomRange).to;
this.onZoomChanged.emit(new TimeRange(from, to));
};
addEventListener('mousemove', listener);
const mouseUpListener = () => {
removeEventListener('mousemove', listener);
removeEventListener('mouseup', mouseUpListener);
};
addEventListener('mouseup', mouseUpListener);
}
startMoveRight(e: any) {
e.preventDefault();
const startPos = e.pageX;
const startOffset = this.getTransformer().transform(
assertDefined(this.zoomRange).to,
);
const listener = (event: any) => {
const movedX = event.pageX - startPos;
const from = assertDefined(this.zoomRange).from;
let to = this.getTransformer().untransform(startOffset + movedX);
if (to.getValueNs() > assertDefined(this.fullRange).to.getValueNs()) {
to = assertDefined(this.fullRange).to;
}
if (to.getValueNs() < assertDefined(this.zoomRange).from.getValueNs()) {
to = assertDefined(this.zoomRange).from;
}
this.onZoomChanged.emit(new TimeRange(from, to));
};
addEventListener('mousemove', listener);
const mouseUpListener = () => {
removeEventListener('mousemove', listener);
removeEventListener('mouseup', mouseUpListener);
};
addEventListener('mouseup', mouseUpListener);
}
}
export const MIN_SLIDER_WIDTH = 30;