control/timeline/TimelineView.tstypescript
import type { EventsHandler } from "../../Events";
import { ButtonGroup } from "../../ui/ButtonGroup";
import { View } from "../../ui/View";
import type { IViewParams, ViewEventsList } from "../../ui/View";
import { ToggleButton } from "../../ui/ToggleButton";
import { TimelineModel } from "./TimelineModel";
import {
addSeconds,
createCanvasHTML,
dateToStr,
drawNotch,
drawText,
formatDate,
getNearestTimeLeft,
getScale
} from "./timelineUtils";
import type { MouseEventExt } from "../../input/MouseHandler";
interface ITimelineViewParams extends IViewParams {
currentDate?: Date;
rangeStart?: Date;
rangeEnd?: Date;
minDate?: Date;
maxDate?: Date;
fillStyle?: string;
use24HourClock?: boolean;
dateTemplate?: string;
}
const SECONDS_TO_MILLISECONDS = 1000.0;
const MILLISECONDS_TO_SECONDS = 1.0 / SECONDS_TO_MILLISECONDS;
const CLICK_MOVE_TOLERANCE_PX = 4;
const PINCH_SCALE_MIN = 0.5;
const PINCH_SCALE_MAX = 2.0;
const TOUCH_MODE_NONE = 0;
const TOUCH_MODE_CURRENT = 1;
const TOUCH_MODE_PINCH = 2;
const TOUCH_MODE_SCALE = 3;
const TOUCH_MODE_SUN = 4;
type TimelineViewEventsList = [
"startdrag",
"stopdrag",
"startdragcurrent",
"stopdragcurrent",
"setcurrent",
"reset",
"play",
"playback",
"pause",
"visibility",
"changelocaltime",
"changelocaldatetime"
];
const TIMELINEVIEW_EVENTS: TimelineViewEventsList = [
"startdrag",
"stopdrag",
"startdragcurrent",
"stopdragcurrent",
"setcurrent",
"reset",
"play",
"playback",
"pause",
"visibility",
"changelocaltime",
"changelocaldatetime"
];
const ICON_PLAY_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
const ICON_PLAY_BACK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M16 5v14L5 12z"/></svg>';
const ICON_PAUSE_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 5h3v14H7zm7 0h3v14h-3z"/></svg>';
const ICON_LOCAL_TIME_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11.9961869,19.0105094 C12.3758826,19.0105094 12.6896778,19.2926633 12.7393402,19.6587388 L12.7461869,19.7605094 L12.7461869,21.2605094 C12.7461869,21.6747229 12.4104004,22.0105094 11.9961869,22.0105094 C11.6164911,22.0105094 11.3026959,21.7283555 11.2530335,21.3622799 L11.2461869,21.2605094 L11.2461869,19.7605094 C11.2461869,19.3462958 11.5819733,19.0105094 11.9961869,19.0105094 Z M18.0178855,16.9376929 L19.0785457,17.9983531 C19.3714389,18.2912463 19.3714389,18.76612 19.0785457,19.0590132 C18.7856525,19.3519065 18.3107787,19.3519065 18.0178855,19.0590132 L16.9572253,17.9983531 C16.6643321,17.7054599 16.6643321,17.2305861 16.9572253,16.9376929 C17.2501185,16.6447997 17.7249923,16.6447997 18.0178855,16.9376929 Z M7.03465577,16.9376929 C7.32754899,17.2305861 7.32754899,17.7054599 7.03465577,17.9983531 L5.97399559,19.0590132 C5.68110238,19.3519065 5.20622864,19.3519065 4.91333542,19.0590132 C4.6204422,18.76612 4.6204422,18.2912463 4.91333542,17.9983531 L5.97399559,16.9376929 C6.26688881,16.6447997 6.74176255,16.6447997 7.03465577,16.9376929 Z M12,6.475 C15.0513732,6.475 17.525,8.94862676 17.525,12 C17.525,15.0513732 15.0513732,17.525 12,17.525 C8.94862676,17.525 6.475,15.0513732 6.475,12 C6.475,8.94862676 8.94862676,6.475 12,6.475 Z M12,7.975 C9.77705388,7.975 7.975,9.77705388 7.975,12 C7.975,14.2229461 9.77705388,16.025 12,16.025 C14.2229461,16.025 16.025,14.2229461 16.025,12 C16.025,9.77705388 14.2229461,7.975 12,7.975 Z M11.25,9 C11.6296958,9 11.943491,9.28215388 11.9931534,9.64822944 L12,9.75 L12,12.003 L13.2496681,12.0037222 C13.6293639,12.0037222 13.9431591,12.2858761 13.9928215,12.6519516 L13.9996681,12.7537222 C13.9996681,13.133418 13.7175142,13.4472132 13.3514387,13.4968756 L13.2496681,13.5037222 L11.25,13.5037222 C10.8703042,13.5037222 10.556509,13.2215683 10.5068466,12.8554928 L10.5,12.7537222 L10.5,9.75 C10.5,9.33578644 10.8357864,9 11.25,9 Z M21.2497537,11.2682976 C21.6639673,11.2682976 21.9997537,11.604084 21.9997537,12.0182976 C21.9997537,12.3979933 21.7175998,12.7117885 21.3515242,12.7614509 L21.2497537,12.7682976 L19.7497537,12.7682976 C19.3355401,12.7682976 18.9997537,12.4325111 18.9997537,12.0182976 C18.9997537,11.6386018 19.2819076,11.3248066 19.6479831,11.2751442 L19.7497537,11.2682976 L21.2497537,11.2682976 Z M4.25024631,11.2394906 C4.66445987,11.2394906 5.00024631,11.5752771 5.00024631,11.9894906 C5.00024631,12.3691864 4.71809243,12.6829816 4.35201687,12.732644 L4.25024631,12.7394906 L2.75024631,12.7394906 C2.33603275,12.7394906 2.00024631,12.4037042 2.00024631,11.9894906 C2.00024631,11.6097949 2.28240019,11.2959997 2.64847575,11.2463372 L2.75024631,11.2394906 L4.25024631,11.2394906 Z M5.88987716,4.86836861 L5.97399559,4.94098676 L7.03465577,6.00164693 C7.32754899,6.29454015 7.32754899,6.76941388 7.03465577,7.0623071 C6.7683892,7.32857367 6.35172552,7.35277972 6.05811403,7.13492526 L5.97399559,7.0623071 L4.91333542,6.00164693 C4.6204422,5.70875371 4.6204422,5.23387998 4.91333542,4.94098676 C5.17960199,4.6747202 5.59626567,4.65051415 5.88987716,4.86836861 Z M19.0785457,4.94098676 C19.3448122,5.20725332 19.3690183,5.623917 19.1511638,5.9175285 L19.0785457,6.00164693 L18.0178855,7.0623071 C17.7249923,7.35520032 17.2501185,7.35520032 16.9572253,7.0623071 C16.6909588,6.79604054 16.6667527,6.37937686 16.8846072,6.08576536 L16.9572253,6.00164693 L18.0178855,4.94098676 C18.3107787,4.64809354 18.7856525,4.64809354 19.0785457,4.94098676 Z M12.0002463,1.98949062 C12.3799421,1.98949062 12.6937373,2.27164451 12.7433997,2.63772007 L12.7502463,2.73949062 L12.7502463,4.23949062 C12.7502463,4.65370419 12.4144599,4.98949062 12.0002463,4.98949062 C11.6205505,4.98949062 11.3067553,4.70733674 11.2570929,4.34126118 L11.2502463,4.23949062 L11.2502463,2.73949062 C11.2502463,2.32527706 11.5860327,1.98949062 12.0002463,1.98949062 Z"/></svg>';
const PLAYBACK_RATES = [0.1, 0.2, 0.5, 1, 2, 4, 8];
const SCALE_FILL_COLOR = "var(--grey1)";
const SPAN_THICKNESS_PX = 3;
const SPAN_MIN_WIDTH_PX = 2;
const SCALE_NOTCH_COLOR = "#bfbfbf";
const SCALE_NOTCH_PM_COLOR = "#737373";
const SCALE_TIME_COLOR = "#bfbfbf";
const TEMPLATE = `<div class="og-timeline">
<div class="og-timeline-top">
</div>
<div class="og-timeline-frame">
<div class="og-timeline-current">
<div class="og-timeline-current-spin"></div>
<div class="og-timeline-current-label"></div>
</div>
<div class="og-timeline-sun">
<div class="og-timeline-sun-spin"></div>
<div class="og-timeline-sun-label"></div>
</div>
<div class="og-timeline-scale"></div>
</div>
<div class="og-timeline-bottom">
<div class="og-timeline-controls">
</div>
<div class="og-timeline-localtime">
</div>
</div>
</div>`;
type TimelineTouchMode =
| typeof TOUCH_MODE_NONE
| typeof TOUCH_MODE_CURRENT
| typeof TOUCH_MODE_PINCH
| typeof TOUCH_MODE_SCALE
| typeof TOUCH_MODE_SUN;
interface ITimelineTouchPointer {
pointerId: number;
clientX: number;
clientY: number;
}
class TimelineView extends View<TimelineModel> {
public override events: EventsHandler<TimelineViewEventsList> & EventsHandler<ViewEventsList>;
public fillStyle: string;
public $controls: HTMLElement | null;
protected _frameEl: HTMLElement | null;
protected _currentEl: HTMLElement | null;
protected _sunEl: HTMLElement | null;
protected _currentLabelEl: HTMLElement | null;
protected _sunLabelEl: HTMLElement | null;
protected _currentLabelText: string;
protected _sunLabelText: string;
protected _dateTemplate: string;
protected _scaleShowTime: boolean;
protected _scaleShowMilliseconds: boolean;
protected _canvasEl: HTMLCanvasElement;
protected _ctx: CanvasRenderingContext2D;
protected _spansCanvasEl: HTMLCanvasElement;
protected _spansCtx: CanvasRenderingContext2D;
protected _appliedFillStyle: string;
protected _isMouseOver: boolean;
protected _isDragging: boolean;
protected _isCurrentDragging: boolean;
protected _isCurrentMouseOver: boolean;
protected _isSunDragging: boolean;
protected _isSunMouseOver: boolean;
protected _localDateTime: Date;
protected _useLocalDateTime: boolean;
protected _use24HourClock: boolean;
protected _minWidth: number;
protected _canvasScale: number;
protected _millisecondsInPixel: number;
protected _clickPosX: number;
protected _clickRangeStart: Date;
protected _clickRangeEnd: Date;
protected _clickCurrentDate: Date;
protected _clickLocalDateTime: Date;
protected _clickTime: number;
protected _clickDelay: number;
protected _clickMoveTolerance: number;
protected _touchMode: TimelineTouchMode;
protected _touchPinchDistance: number;
protected _touchPointers: Map<number, ITimelineTouchPointer>;
protected _touchScalePointerId: number | null;
protected _touchCurrentPointerId: number | null;
protected _touchSunPointerId: number | null;
protected _onResizeObserver_: () => void;
protected _resizeObserver: ResizeObserver;
protected _pauseBtn: ToggleButton;
protected _playBtn: ToggleButton;
protected _playBackBtn: ToggleButton;
protected _multiplierEl: HTMLSelectElement | null;
protected _buttons: ButtonGroup;
protected _localTimeBtn: ToggleButton;
protected _visibility: boolean;
constructor(options: ITimelineViewParams = {}) {
super({
template: TEMPLATE,
model: new TimelineModel({
rangeStart: options.rangeStart,
rangeEnd: options.rangeEnd,
current: options.currentDate,
minDate: options.minDate,
maxDate: options.maxDate
})
});
//@ts-ignore
this.events = this.events.registerNames(TIMELINEVIEW_EVENTS);
this.fillStyle = options.fillStyle || SCALE_FILL_COLOR;
this.$controls = null;
this._frameEl = null;
this._currentEl = null;
this._sunEl = null;
this._currentLabelEl = null;
this._sunLabelEl = null;
this._currentLabelText = "";
this._sunLabelText = "";
this._dateTemplate = options.dateTemplate || "";
this._scaleShowTime = true;
this._scaleShowMilliseconds = false;
this._canvasEl = createCanvasHTML();
this._ctx = this._canvasEl.getContext("2d")!;
this._spansCanvasEl = createCanvasHTML();
this._spansCanvasEl.classList.add("og-timeline-spans");
this._spansCtx = this._spansCanvasEl.getContext("2d")!;
this._appliedFillStyle = "";
this._isMouseOver = false;
this._isDragging = false;
this._isCurrentDragging = false;
this._isCurrentMouseOver = false;
this._isSunDragging = false;
this._isSunMouseOver = false;
this._localDateTime = this.model.current;
this._useLocalDateTime = false;
this._use24HourClock = options.use24HourClock || false;
this._minWidth = 330;
this._canvasScale = 2;
this._millisecondsInPixel = 0;
this._clickPosX = 0;
this._clickRangeStart = new Date();
this._clickRangeEnd = new Date();
this._clickCurrentDate = new Date();
this._clickLocalDateTime = new Date();
this._clickTime = 0;
this._clickDelay = 450;
this._clickMoveTolerance = CLICK_MOVE_TOLERANCE_PX;
this._touchMode = TOUCH_MODE_NONE;
this._touchPinchDistance = 0;
this._touchPointers = new Map();
this._touchScalePointerId = null;
this._touchCurrentPointerId = null;
this._touchSunPointerId = null;
this._onResizeObserver_ = this._onResizeObserver.bind(this);
this._resizeObserver = new ResizeObserver(this._onResizeObserver_);
this._pauseBtn = new ToggleButton({
classList: ["og-timeline-control_button"],
icon: ICON_PAUSE_SVG,
name: "pause"
});
this._playBtn = new ToggleButton({
classList: ["og-timeline-control_button"],
icon: ICON_PLAY_SVG,
name: "play"
});
this._playBackBtn = new ToggleButton({
classList: ["og-timeline-control_button"],
icon: ICON_PLAY_BACK_SVG,
name: "playback"
});
this._multiplierEl = null;
this._buttons = new ButtonGroup({
buttons: [this._pauseBtn, this._playBtn, this._playBackBtn]
});
this._localTimeBtn = new ToggleButton({
classList: ["og-suncontrol-button"],
icon: ICON_LOCAL_TIME_SVG,
title: "Local time - Shift-drag to set"
});
this._visibility = false;
}
protected _onResizeObserver() {
this.resize();
}
public get canvasScale(): number {
return this._canvasScale;
}
public set canvasScale(scale: number) {
if (scale !== this._canvasScale) {
this._canvasScale = scale;
this.resize();
}
}
public resize() {
this._resize();
this.draw();
}
public override afterRender(parentNode: HTMLElement) {
this.resize();
}
public override render(): this {
super.render();
this.$controls = this.select(".og-timeline-controls");
this._frameEl = this.select(".og-timeline-frame");
this._currentEl = this.select(".og-timeline-current");
this._sunEl = this.select(".og-timeline-sun");
this._currentLabelEl = this.select(".og-timeline-current-label");
this._sunLabelEl = this.select(".og-timeline-sun-label");
this.select(".og-timeline-frame .og-timeline-scale")!.appendChild(this._canvasEl);
this._frameEl!.insertBefore(this._spansCanvasEl, this._frameEl!.firstChild);
this._resizeObserver.observe(this.el!);
this.model.events.on("change", () => {
this.draw();
});
this.model.events.on("current", (d: Date) => {
this._drawCurrent();
this.events.dispatch(this.events.setcurrent, d);
});
this.model.events.on("spanschange", this._drawSpans);
this._canvasEl.addEventListener("mouseenter", this._onMouseEnter);
this._canvasEl.addEventListener("mouseout", this._onMouseOut);
this._canvasEl.addEventListener("pointerdown", this._onScalePointerDown);
this._canvasEl.style.touchAction = "none";
this._currentEl!.addEventListener("mouseenter", this._onCurrentMouseEnter);
this._currentEl!.addEventListener("mouseleave", this._onCurrentMouseLeave);
this._currentEl!.addEventListener("pointerdown", this._onCurrentPointerDown);
this._currentEl!.style.touchAction = "none";
this._sunEl!.addEventListener("mouseenter", this._onSunMouseEnter);
this._sunEl!.addEventListener("mouseleave", this._onSunMouseLeave);
this._sunEl!.addEventListener("pointerdown", this._onSunPointerDown);
this._sunEl!.style.touchAction = "none";
document.body.addEventListener("mousemove", this._onMouseMove);
document.body.addEventListener("mousedown", this._onMouseDown);
document.body.addEventListener("mouseup", this._onMouseUp);
document.body.addEventListener("wheel", this._onMouseWheelFF);
document.addEventListener("pointermove", this._onPointerMove);
document.addEventListener("pointerup", this._onPointerUp);
document.addEventListener("pointercancel", this._onPointerUp);
this._playBackBtn.appendTo(this.$controls!);
this._playBtn.appendTo(this.$controls!);
this._pauseBtn.appendTo(this.$controls!);
this._multiplierEl = this._createMultiplier();
this.$controls!.appendChild(this._multiplierEl);
this._syncPlayButtons();
this.model.events.on("play", this._syncPlayButtons);
this.model.events.on("stop", this._syncPlayButtons);
this._buttons.events.on("change", (btn: ToggleButton) => {
switch (btn.name) {
case "play":
this.play();
break;
case "playback":
this.playBack();
break;
case "pause":
this.pause();
break;
}
});
this._localTimeBtn.appendTo(this.select(".og-timeline-localtime")!);
this._localTimeBtn.events.on("change", (isActive: boolean) => {
this._useLocalDateTime = isActive;
this._drawSun();
this.events.dispatch(this.events.changelocaltime, isActive);
});
this._playBtn.events.on("change", (isActive: boolean) => {
if (!isActive && !this._pauseBtn.isActive && !this._playBackBtn.isActive) {
this.pause();
}
});
this._playBackBtn.events.on("change", (isActive: boolean) => {
if (!isActive && !this._pauseBtn.isActive && !this._playBtn.isActive) {
this.pause();
}
});
this.setVisibility(true);
return this;
}
public get use24HourClock(): boolean {
return this._use24HourClock;
}
public set use24HourClock(use24HourClock: boolean) {
if (this._use24HourClock !== use24HourClock) {
this._use24HourClock = use24HourClock;
this.draw();
}
}
protected _notchColor(time: number): string {
if (!this._use24HourClock && new Date(time).getUTCHours() >= 12) {
return SCALE_NOTCH_PM_COLOR;
}
return SCALE_NOTCH_COLOR;
}
/**
* Marker label date format, e.g. "MM/dd/yyyy" or "hh:mm:ss.ms".
* An empty template keeps the adaptive scale format.
*/
public get dateTemplate(): string {
return this._dateTemplate;
}
public set dateTemplate(dateTemplate: string) {
if (this._dateTemplate !== dateTemplate) {
this._dateTemplate = dateTemplate;
this._drawCurrent();
this._drawSun();
}
}
protected _formatMarkerDate(date: Date): string {
if (this._dateTemplate) {
return formatDate(date, this._dateTemplate, this._use24HourClock);
}
return dateToStr(date, this._scaleShowTime, this._scaleShowMilliseconds, this._use24HourClock);
}
public get localTime(): boolean {
return this._useLocalDateTime;
}
public set localTime(localTime: boolean) {
this._useLocalDateTime = localTime;
this._localTimeBtn.setActive(localTime, true);
this._drawSun();
}
/**
* Date the Sun marker stands on.
* @public
* @type {Date}
*/
public get localDateTime(): Date {
return this._localDateTime;
}
public set localDateTime(date: Date) {
this._localDateTime = date;
this._drawSun();
}
public setVisibility(visibility: boolean) {
if (visibility !== this._visibility) {
this._visibility = visibility;
if (this.el) {
if (visibility) {
this.el.style.display = "block";
} else {
this.el.style.display = "none";
}
}
this.events.dispatch(this.events.visibility, visibility);
}
}
protected _createMultiplier(): HTMLSelectElement {
const el = document.createElement("select");
el.className = "og-timeline-multiplier";
el.title = "Playback rate";
for (const rate of PLAYBACK_RATES) {
const option = document.createElement("option");
option.value = String(rate);
option.textContent = `${rate}x`;
el.appendChild(option);
}
el.addEventListener("change", () => {
const rate = Number(el.value);
this.model.multiplier = this.model.multiplier < 0 ? -rate : rate;
});
return el;
}
protected _syncMultiplier() {
if (this._multiplierEl) {
this._multiplierEl.value = String(Math.abs(this.model.multiplier));
}
}
protected _syncPlayButtons = () => {
const stopped = this.model.stopped();
const backwards = this.model.multiplier < 0;
this._pauseBtn.setActive(stopped, true);
this._pauseBtn.preventClick = stopped;
this._playBtn.setActive(!stopped && !backwards, true);
this._playBtn.preventClick = false;
this._playBackBtn.setActive(!stopped && backwards, true);
this._playBackBtn.preventClick = false;
this._syncMultiplier();
};
public reset() {
this.model.stop();
this.events.dispatch(this.events.reset, this.model);
}
public play() {
this.model.multiplier = Math.abs(this.model.multiplier);
this.model.play();
this._syncPlayButtons();
this.events.dispatch(this.events.play, this.model);
}
public pause() {
this.model.stop();
this._syncPlayButtons();
this.events.dispatch(this.events.pause, this.model);
}
public playBack() {
this.model.multiplier = -1 * Math.abs(this.model.multiplier);
this.model.play();
this._syncPlayButtons();
this.events.dispatch(this.events.playback, this.model);
}
protected _onMouseWheel = (e: MouseEventExt) => {
if (this._isMouseOver) {
let rect = this._canvasEl.getBoundingClientRect();
let pointerPosX = e.clientX - rect.left,
pointerCenterOffsetX = -(pointerPosX - this.clientWidth * 0.5);
let pointerTime = this.model.rangeStartTime + this._millisecondsInPixel * pointerPosX;
this._zoom(pointerTime, pointerCenterOffsetX, Math.sign(e.wheelDelta!));
} else if (this._isSunMouseOver) {
let pointerCenterOffsetX = -(
(this._localDateTime.getTime() - this.model.rangeStartTime) / this._millisecondsInPixel -
this.clientWidth * 0.5
);
this._zoom(this._localDateTime.getTime(), pointerCenterOffsetX, Math.sign(e.wheelDelta!));
} else if (this._isCurrentMouseOver) {
let pointerCenterOffsetX = -(
(this.model.currentTime - this.model.rangeStartTime) / this._millisecondsInPixel -
this.clientWidth * 0.5
);
this._zoom(this.model.currentTime, pointerCenterOffsetX, Math.sign(e.wheelDelta!));
}
};
protected _onMouseWheelFF = (e: MouseEventExt) => {
this._onMouseWheel(e);
};
protected _zoom(pointerTime: number, pointerCenterOffsetX: number, dir: number) {
this._zoomByScale(pointerTime, pointerCenterOffsetX, 1 - dir * 0.1);
}
protected _zoomByScale(pointerTime: number, pointerCenterOffsetX: number, scale: number) {
if (!isFinite(scale) || scale <= 0) {
return;
}
let centerTime = this.model.rangeStartTime + 0.5 * this.model.range;
let centerTimeOffset = (pointerTime - centerTime) * MILLISECONDS_TO_SECONDS;
let rangeStart = addSeconds(this.model.rangeStart, centerTimeOffset),
rangeEnd = addSeconds(this.model.rangeEnd, centerTimeOffset);
let shiftedRange = rangeEnd.getTime() - rangeStart.getTime();
let scaledRange = shiftedRange * scale;
let shiftedCenter = rangeStart.getTime() + shiftedRange * 0.5;
let newRangeStart = new Date(shiftedCenter - scaledRange * 0.5);
let newRangeEnd = new Date(shiftedCenter + scaledRange * 0.5);
let msPx = scaledRange / this.clientWidth;
if (msPx < 31536000000 && msPx > 0.1) {
let timeOffset = msPx * pointerCenterOffsetX * MILLISECONDS_TO_SECONDS;
this.model.set(addSeconds(newRangeStart, timeOffset), addSeconds(newRangeEnd, timeOffset));
}
}
protected _startScaleDrag(
clientX: number,
sourceEvent: Event,
allowClick: boolean = true,
pointerId: number | null = this._touchScalePointerId
) {
if (!this._isDragging) {
this._isDragging = true;
document.body.classList.add("og-timeline-unselectable");
this.events.dispatch(this.events.startdrag, sourceEvent);
}
this._clickPosX = clientX;
this._clickRangeStart = this.model.rangeStart;
this._clickRangeEnd = this.model.rangeEnd;
this._clickTime = allowClick ? Date.now() : 0;
this._touchScalePointerId = pointerId;
}
protected _moveScaleDrag(clientX: number) {
let offsetSec = (this._clickPosX - clientX) * this._millisecondsInPixel * MILLISECONDS_TO_SECONDS;
this.model.set(addSeconds(this._clickRangeStart, offsetSec), addSeconds(this._clickRangeEnd, offsetSec));
}
protected _stopScaleDrag(clientX: number | null, allowClick: boolean = true) {
if (!this._isDragging) {
return;
}
this._isDragging = false;
this._touchScalePointerId = null;
document.body.classList.remove("og-timeline-unselectable");
if (allowClick && clientX != null && this._isClickGesture(clientX)) {
let current = this._setCurrentByClientX(clientX);
this.events.dispatch(this.events.stopdrag, current);
this.events.dispatch(this.events.setcurrent, current);
} else {
this.events.dispatch(this.events.stopdrag, this.model.current);
}
}
protected _startCurrentDrag(
clientX: number,
sourceEvent: Event,
pointerId: number | null = this._touchCurrentPointerId
) {
if (!this._isCurrentDragging) {
this._isCurrentDragging = true;
document.body.classList.add("og-timeline-unselectable");
this.events.dispatch(this.events.startdragcurrent, sourceEvent);
}
this._clickPosX = clientX;
this._clickCurrentDate = this.model.current;
this._touchCurrentPointerId = pointerId;
}
protected _moveCurrentDrag(clientX: number) {
let offsetSec = (this._clickPosX - clientX) * this._millisecondsInPixel * MILLISECONDS_TO_SECONDS;
let newCurrent = addSeconds(this._clickCurrentDate, -offsetSec);
if (newCurrent >= this.model.rangeStart && newCurrent <= this.model.rangeEnd) {
this.model.current = newCurrent;
}
}
protected _stopCurrentDrag() {
if (!this._isCurrentDragging) {
return;
}
this._isCurrentDragging = false;
this._touchCurrentPointerId = null;
document.body.classList.remove("og-timeline-unselectable");
this.events.dispatch(this.events.stopdragcurrent, this.model.current);
}
protected _startSunDrag(clientX: number, pointerId: number | null = this._touchSunPointerId) {
if (!this._isSunDragging) {
this._isSunDragging = true;
document.body.classList.add("og-timeline-unselectable");
}
this._clickPosX = clientX;
this._clickLocalDateTime = this._localDateTime;
this._touchSunPointerId = pointerId;
}
/** The Sun marker carries its own date: the model, and everything reading it, stay put. */
protected _moveSunDrag(clientX: number) {
const offsetSec = (this._clickPosX - clientX) * this._millisecondsInPixel * MILLISECONDS_TO_SECONDS;
const localDateTime = addSeconds(this._clickLocalDateTime, -offsetSec);
if (localDateTime >= this.model.rangeStart && localDateTime <= this.model.rangeEnd) {
this._localDateTime = localDateTime;
this._drawSun();
this.events.dispatch(this.events.changelocaldatetime, localDateTime);
}
}
protected _stopSunDrag() {
if (!this._isSunDragging) {
return;
}
this._isSunDragging = false;
this._touchSunPointerId = null;
document.body.classList.remove("og-timeline-unselectable");
}
protected _isClickGesture(clientX: number): boolean {
return (
this._clickTime > 0 &&
Math.abs(this._clickPosX - clientX) <= this._clickMoveTolerance &&
Date.now() - this._clickTime < this._clickDelay
);
}
protected _setSunByClientX(clientX: number): Date {
const rect = this._canvasEl.getBoundingClientRect();
const posX = Math.max(0, Math.min(clientX - rect.left, this.clientWidth));
const localDateTime = new Date(this.model.rangeStartTime + posX * this._millisecondsInPixel);
this._localDateTime = localDateTime;
this._drawSun();
return localDateTime;
}
protected _setCurrentByClientX(clientX: number): Date {
let rect = this._canvasEl.getBoundingClientRect();
let posX = Math.max(0, Math.min(clientX - rect.left, this.clientWidth));
let current = new Date(this.model.rangeStartTime + posX * this._millisecondsInPixel);
this.model.current = current;
return current;
}
protected _captureTouchPointer(e: PointerEvent) {
this._touchPointers.set(e.pointerId, {
pointerId: e.pointerId,
clientX: e.clientX,
clientY: e.clientY
});
(e.currentTarget as Element | null)?.setPointerCapture?.(e.pointerId);
}
protected _updateTouchPointer(e: PointerEvent) {
this._touchPointers.set(e.pointerId, {
pointerId: e.pointerId,
clientX: e.clientX,
clientY: e.clientY
});
}
protected _releaseTouchPointerCapture(pointerId: number) {
if (this._canvasEl.hasPointerCapture(pointerId)) {
this._canvasEl.releasePointerCapture(pointerId);
}
if (this._currentEl?.hasPointerCapture(pointerId)) {
this._currentEl.releasePointerCapture(pointerId);
}
}
protected _getFirstTouchPointer(): ITimelineTouchPointer | null {
const iter = this._touchPointers.values();
const first = iter.next();
return first.done ? null : first.value;
}
protected _getTwoTouchPointers(): [ITimelineTouchPointer, ITimelineTouchPointer] | null {
const iter = this._touchPointers.values();
const first = iter.next();
const second = iter.next();
if (first.done || second.done) {
return null;
}
return [first.value, second.value];
}
protected _getTouchPointer(pointerId: number | null): ITimelineTouchPointer | null {
if (pointerId == null) {
return null;
}
return this._touchPointers.get(pointerId) || null;
}
protected _getTouchDistance(t0: ITimelineTouchPointer, t1: ITimelineTouchPointer): number {
return Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
}
protected _startPinchGesture(t0: ITimelineTouchPointer, t1: ITimelineTouchPointer, sourceEvent: Event) {
this._startScaleDrag((t0.clientX + t1.clientX) * 0.5, sourceEvent, false, null);
this._touchMode = TOUCH_MODE_PINCH;
this._touchPinchDistance = this._getTouchDistance(t0, t1);
}
protected _movePinchGesture(t0: ITimelineTouchPointer, t1: ITimelineTouchPointer) {
let distance = this._getTouchDistance(t0, t1);
if (distance <= 0 || this._touchPinchDistance <= 0) {
return;
}
let scale = this._touchPinchDistance / distance;
if (scale < PINCH_SCALE_MIN) {
scale = PINCH_SCALE_MIN;
} else if (scale > PINCH_SCALE_MAX) {
scale = PINCH_SCALE_MAX;
}
let centerX = (t0.clientX + t1.clientX) * 0.5;
let rect = this._canvasEl.getBoundingClientRect();
let pointerPosX = centerX - rect.left;
let pointerCenterOffsetX = -(pointerPosX - this.clientWidth * 0.5);
let pointerTime = this.model.rangeStartTime + this._millisecondsInPixel * pointerPosX;
this._zoomByScale(pointerTime, pointerCenterOffsetX, scale);
this._touchPinchDistance = distance;
}
protected _onMouseDown = (e: MouseEvent) => {
if (this._isMouseOver) {
if (e.shiftKey && this._useLocalDateTime) {
this.events.dispatch(this.events.changelocaldatetime, this._setSunByClientX(e.clientX));
this._startSunDrag(e.clientX);
return;
}
this._startScaleDrag(e.clientX, e);
} else if (this._isSunMouseOver) {
this._startSunDrag(e.clientX);
} else if (this._isCurrentMouseOver) {
this._startCurrentDrag(e.clientX, e);
}
};
protected _onMouseUp = (e: MouseEvent) => {
if (this._isDragging) {
this._stopScaleDrag(e.clientX);
} else if (this._isSunDragging) {
this._stopSunDrag();
} else if (this._isCurrentDragging) {
this._stopCurrentDrag();
}
};
protected _onMouseEnter = () => {
this._isMouseOver = true;
};
protected _onMouseOut = () => {
this._isMouseOver = false;
};
protected _onCurrentMouseEnter = () => {
this._isCurrentMouseOver = true;
};
protected _onCurrentMouseLeave = () => {
this._isCurrentMouseOver = false;
};
protected _onSunMouseEnter = () => {
this._isSunMouseOver = true;
};
protected _onSunMouseLeave = () => {
this._isSunMouseOver = false;
};
protected _onScalePointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") {
return;
}
this._captureTouchPointer(e);
if (this._isCurrentDragging || this._isSunDragging) {
return;
}
const pointersCount = this._touchPointers.size;
if (pointersCount === 1) {
this._touchMode = TOUCH_MODE_SCALE;
this._startScaleDrag(e.clientX, e, true, e.pointerId);
e.preventDefault();
} else if (pointersCount >= 2) {
const pair = this._getTwoTouchPointers();
if (pair) {
this._startPinchGesture(pair[0], pair[1], e);
}
e.preventDefault();
}
};
protected _onCurrentPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") {
return;
}
this._captureTouchPointer(e);
if (this._touchPointers.size !== 1) {
return;
}
this._touchMode = TOUCH_MODE_CURRENT;
this._startCurrentDrag(e.clientX, e, e.pointerId);
e.preventDefault();
};
protected _onSunPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") {
return;
}
this._captureTouchPointer(e);
if (this._touchPointers.size !== 1) {
return;
}
this._touchMode = TOUCH_MODE_SUN;
this._startSunDrag(e.clientX, e.pointerId);
e.preventDefault();
};
protected _onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "touch" || !this._touchPointers.has(e.pointerId)) {
return;
}
this._updateTouchPointer(e);
if (this._touchMode === TOUCH_MODE_NONE) {
return;
}
if (this._touchMode === TOUCH_MODE_CURRENT) {
if (this._touchPointers.size === 1 && this._touchCurrentPointerId === e.pointerId) {
this._moveCurrentDrag(e.clientX);
e.preventDefault();
}
return;
}
if (this._touchMode === TOUCH_MODE_SUN) {
if (this._touchPointers.size === 1 && this._touchSunPointerId === e.pointerId) {
this._moveSunDrag(e.clientX);
e.preventDefault();
}
return;
}
if (this._touchPointers.size >= 2) {
const pair = this._getTwoTouchPointers();
if (!pair) {
return;
}
if (this._touchMode !== TOUCH_MODE_PINCH) {
this._startPinchGesture(pair[0], pair[1], e);
}
this._movePinchGesture(pair[0], pair[1]);
e.preventDefault();
return;
}
if (this._touchPointers.size === 1) {
const pointer = this._getTouchPointer(this._touchScalePointerId) || this._getFirstTouchPointer();
if (!pointer) {
return;
}
if (this._touchMode === TOUCH_MODE_PINCH) {
this._touchMode = TOUCH_MODE_SCALE;
this._startScaleDrag(pointer.clientX, e, false, pointer.pointerId);
}
if (this._touchMode === TOUCH_MODE_SCALE) {
this._moveScaleDrag(pointer.clientX);
e.preventDefault();
}
}
};
protected _onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") {
return;
}
const endedPointer = this._touchPointers.get(e.pointerId) || {
pointerId: e.pointerId,
clientX: e.clientX,
clientY: e.clientY
};
this._touchPointers.delete(e.pointerId);
this._releaseTouchPointerCapture(e.pointerId);
if (this._touchMode === TOUCH_MODE_NONE) {
return;
}
if (this._touchMode === TOUCH_MODE_CURRENT) {
if (this._touchPointers.size === 0) {
this._stopCurrentDrag();
this._touchMode = TOUCH_MODE_NONE;
e.preventDefault();
} else if (this._touchCurrentPointerId === e.pointerId) {
const nextPointer = this._getFirstTouchPointer();
if (nextPointer) {
this._startCurrentDrag(nextPointer.clientX, e, nextPointer.pointerId);
}
}
return;
}
if (this._touchMode === TOUCH_MODE_SUN) {
if (this._touchPointers.size === 0) {
this._stopSunDrag();
this._touchMode = TOUCH_MODE_NONE;
e.preventDefault();
} else if (this._touchSunPointerId === e.pointerId) {
const nextPointer = this._getFirstTouchPointer();
if (nextPointer) {
this._startSunDrag(nextPointer.clientX, nextPointer.pointerId);
}
}
return;
}
if (this._touchPointers.size >= 2) {
const pair = this._getTwoTouchPointers();
if (!pair) {
return;
}
if (this._touchMode !== TOUCH_MODE_PINCH) {
this._startPinchGesture(pair[0], pair[1], e);
}
return;
}
if (this._touchPointers.size === 1) {
const pointer = this._getFirstTouchPointer();
if (!pointer) {
return;
}
if (this._touchMode === TOUCH_MODE_PINCH) {
this._touchMode = TOUCH_MODE_SCALE;
this._startScaleDrag(pointer.clientX, e, false, pointer.pointerId);
e.preventDefault();
} else if (this._touchMode === TOUCH_MODE_SCALE && this._touchScalePointerId === e.pointerId) {
this._startScaleDrag(pointer.clientX, e, false, pointer.pointerId);
}
return;
}
if (this._touchMode === TOUCH_MODE_PINCH) {
this._stopScaleDrag(null, false);
} else {
this._stopScaleDrag(endedPointer.clientX, true);
}
this._touchMode = TOUCH_MODE_NONE;
e.preventDefault();
};
protected _onMouseMove = (e: MouseEvent) => {
if (this._isDragging) {
this._moveScaleDrag(e.clientX);
} else if (this._isSunDragging) {
this._moveSunDrag(e.clientX);
} else if (this._isCurrentDragging) {
this._moveCurrentDrag(e.clientX);
}
};
public get clientWidth(): number {
return this._canvasEl ? this._canvasEl.width / this._canvasScale : 0;
}
public get clientHeight(): number {
return this._canvasEl ? this._canvasEl.height / this._canvasScale : 0;
}
protected _resize() {
if (this._frameEl) {
for (const canvas of [this._canvasEl, this._spansCanvasEl]) {
canvas.width = this._frameEl.clientWidth * this._canvasScale;
canvas.height = this._frameEl.clientHeight * this._canvasScale;
canvas.style.width = `${this._frameEl.clientWidth}px`;
canvas.style.height = `${this._frameEl.clientHeight}px`;
}
}
}
public getOffsetByTime(milliseconds: number): number {
return (milliseconds - this.model.rangeStartTime) / this._millisecondsInPixel;
}
protected _clearEvents() {
this._canvasEl.removeEventListener("mouseenter", this._onMouseEnter);
this._canvasEl.removeEventListener("mouseout", this._onMouseOut);
this._canvasEl.removeEventListener("pointerdown", this._onScalePointerDown);
if (this._currentEl) {
this._currentEl.removeEventListener("mouseenter", this._onCurrentMouseEnter);
this._currentEl.removeEventListener("mouseleave", this._onCurrentMouseLeave);
this._currentEl.removeEventListener("pointerdown", this._onCurrentPointerDown);
}
document.body.removeEventListener("mousemove", this._onMouseMove);
document.body.removeEventListener("mousedown", this._onMouseDown);
document.body.removeEventListener("mouseup", this._onMouseUp);
document.body.removeEventListener("wheel", this._onMouseWheelFF);
document.removeEventListener("pointermove", this._onPointerMove);
document.removeEventListener("pointerup", this._onPointerUp);
document.removeEventListener("pointercancel", this._onPointerUp);
for (const pointerId of this._touchPointers.keys()) {
this._releaseTouchPointerCapture(pointerId);
}
this._touchPointers.clear();
this._touchScalePointerId = null;
this._touchCurrentPointerId = null;
this._touchMode = TOUCH_MODE_NONE;
document.body.classList.remove("og-timeline-unselectable");
}
public override remove() {
this._clearEvents();
this.model.events.off("play", this._syncPlayButtons);
this.model.events.off("stop", this._syncPlayButtons);
this.model.events.off("spanschange", this._drawSpans);
this._resizeObserver.disconnect();
super.remove();
this.model.stop();
}
protected _clearCanvas() {
this._applyScaleBackground();
this._ctx.clearRect(0, 0, this.clientWidth * this._canvasScale, this.clientHeight * this._canvasScale);
}
protected _applyScaleBackground() {
if (this._frameEl && this._appliedFillStyle !== this.fillStyle) {
this._appliedFillStyle = this.fillStyle;
this._frameEl.style.background = this.fillStyle;
}
}
protected _drawSpans = () => {
const width = this.clientWidth;
const height = this.clientHeight;
const scale = this._canvasScale;
this._spansCtx.clearRect(0, 0, width * scale, height * scale);
const laneCount = this.model.laneCount;
if (!laneCount || !width) return;
const pitch = Math.max(height / (laneCount + 1), SPAN_THICKNESS_PX);
const top = (height - pitch * (laneCount + 1)) * 0.5;
for (const span of this.model.getSpans()) {
const left = this.getOffsetByTime(span.start.getTime());
const right = this.getOffsetByTime(span.end.getTime());
if (right < 0 || left > width) continue;
const x = Math.max(0, left);
const spanWidth = Math.max(Math.min(right, width) - x, SPAN_MIN_WIDTH_PX);
const y = top + pitch * (span.lane + 1) - SPAN_THICKNESS_PX * 0.5;
this._spansCtx.fillStyle = span.color;
this._spansCtx.fillRect(x * scale, y * scale, spanWidth * scale, SPAN_THICKNESS_PX * scale);
}
};
protected _drawCurrent() {
let curPosX = (this.model.currentTime - this.model.rangeStartTime) / this._millisecondsInPixel;
if (this.model.current < this.model.rangeStart || this.model.current > this.model.rangeEnd) {
this._currentEl!.style.display = "none";
} else {
this._currentEl!.style.display = "block";
this._currentEl!.style.transform = `translateX(${curPosX}px)`;
this._updateLabel(this._currentLabelEl, this.model.current, "_currentLabelText", curPosX);
}
}
protected _updateLabel(
el: HTMLElement | null,
date: Date,
textField: "_currentLabelText" | "_sunLabelText",
posX: number
) {
if (!el) return;
const text = this._formatMarkerDate(date);
if (text !== this[textField]) {
this[textField] = text;
el.textContent = text;
}
// Keeps the label inside the widget
const overhang = 10;
const half = el.offsetWidth * 0.5;
let delta = 0;
if (posX - half < -overhang) {
delta = -overhang - (posX - half);
} else if (posX + half > this.clientWidth + overhang) {
delta = this.clientWidth + overhang - (posX + half);
}
el.style.transform = `translateX(calc(-50% + ${delta}px))`;
}
protected _drawSun() {
if (!this._sunEl || !this._millisecondsInPixel) return;
if (
!this._useLocalDateTime ||
this._localDateTime < this.model.rangeStart ||
this._localDateTime > this.model.rangeEnd
) {
this._sunEl.style.display = "none";
return;
}
const posX = (this._localDateTime.getTime() - this.model.rangeStartTime) / this._millisecondsInPixel;
this._sunEl.style.display = "block";
this._sunEl.style.transform = `translateX(${posX}px)`;
this._updateLabel(this._sunLabelEl, this._localDateTime, "_sunLabelText", posX);
}
public draw() {
this._millisecondsInPixel = this.model.range / this.clientWidth;
let minWidthMs = this._minWidth * this._millisecondsInPixel;
let scaleData = getScale(minWidthMs * MILLISECONDS_TO_SECONDS);
if (scaleData) {
this._clearCanvas();
let scaleMs = scaleData[0] * SECONDS_TO_MILLISECONDS,
scalePx = scaleMs / this._millisecondsInPixel,
segCount = scaleData[1];
let originTime = getNearestTimeLeft(this.model.rangeStartTime, scaleMs);
let showMilliseconds = scaleData[0] < 1.0,
showTime = scaleData[0] < 86400.0;
this._scaleShowTime = showTime;
this._scaleShowMilliseconds = showMilliseconds;
for (let i = originTime, rangeEnd = this.model.rangeEndTime + scaleMs; i < rangeEnd; i += scaleMs) {
let x = this.getOffsetByTime(i);
if (x >= 0 && x <= this.clientWidth * this._canvasScale) {
drawNotch(
this._ctx,
x * this._canvasScale,
10 * this._canvasScale,
2 * this._canvasScale,
this._notchColor(i)
);
}
for (let j = 1; j < segCount; j++) {
let xx = x + j * (scalePx / segCount);
if (xx >= 0 && xx <= this.clientWidth * this._canvasScale) {
drawNotch(
this._ctx,
xx * this._canvasScale,
5 * this._canvasScale,
this._canvasScale,
this._notchColor(i + j * (scaleMs / segCount))
);
}
}
drawText(
this._ctx,
dateToStr(new Date(i), showTime, showMilliseconds, this._use24HourClock),
x * this._canvasScale,
26 * this._canvasScale,
"24px monospace",
SCALE_TIME_COLOR,
"center"
);
}
this._drawCurrent();
this._drawSun();
}
this._drawSpans();
}
}
export { TimelineView };