control/timeline/TimelineModel.tstypescript
import { type EventsHandler, createEvents } from "../../Events";
import { addSeconds } from "./timelineUtils";
type TimelineEventsList = ["change", "current", "play", "stop", "spanschange"];
const TIMELINE_EVENTS: TimelineEventsList = ["change", "current", "play", "stop", "spanschange" /*, "tick"*/];
const DEFAULT_SPAN_COLOR = "rgb(0, 180, 255)";
/** A colored time interval shown on the timeline scale. */
export interface ITimelineSpanParams {
id?: string;
start: Date;
end: Date;
color?: string;
data?: unknown;
}
export interface ITimelineSpan {
id: string;
start: Date;
end: Date;
color: string;
data?: unknown;
/** Row the span is drawn in, assigned so that overlapping spans never share one. */
lane: number;
}
interface ITimelineParams {
current?: Date;
rangeStart?: Date;
rangeEnd?: Date;
minDate?: Date | null;
maxDate?: Date | null;
multiplier?: number;
}
class TimelineModel {
static __spanCounter__: number = 0;
public events: EventsHandler<TimelineEventsList>;
protected _current: Date;
protected _rangeStart: Date;
protected _rangeEnd: Date;
protected _range: number;
protected _minDate: Date | null;
protected _maxDate: Date | null;
protected _requestAnimationFrameId: number;
protected _prevNow: number;
protected _spans: ITimelineSpan[];
protected _laneCount: number;
public multiplier: number;
public dt: number;
/**
* While set, playing does not advance
* the time: something else sets `current` instead.
*/
public driven: boolean;
/**
* While set, playing takes the current time
* from this source instead of the frame delta.
*/
public clock: (() => number) | null;
constructor(options: ITimelineParams = {}) {
this.events = createEvents(TIMELINE_EVENTS);
this._current = options.current || new Date();
this._rangeStart = options.rangeStart || new Date();
this._rangeEnd = options.rangeEnd || addSeconds(this._rangeStart, 3600);
this._range = this._rangeEnd.getTime() - this._rangeStart.getTime();
this._minDate = options.minDate || null;
this._maxDate = options.maxDate || null;
this.multiplier = options.multiplier != undefined ? options.multiplier : 1.0;
this._requestAnimationFrameId = 0;
this._prevNow = 0;
this._spans = [];
this._laneCount = 0;
this.driven = false;
this.clock = null;
this.dt = 0;
}
public addSpan(params: ITimelineSpanParams): ITimelineSpan {
return this.addSpans([params])[0];
}
public addSpans(params: ITimelineSpanParams[]): ITimelineSpan[] {
const added = params.map((item) => toSpan(item));
this._spans = this._spans.concat(added);
this._onSpansChange();
return added;
}
public setSpans(params: ITimelineSpanParams[]): ITimelineSpan[] {
this._spans = params.map((item) => toSpan(item));
this._onSpansChange();
return this._spans;
}
public updateSpan(id: string, params: ITimelineSpanParams): ITimelineSpan | undefined {
const span = this.getSpan(id);
if (!span) return;
span.start = params.start;
span.end = params.end;
span.color = params.color || DEFAULT_SPAN_COLOR;
span.data = params.data;
this._onSpansChange();
return span;
}
public removeSpan(id: string): void {
const rest = this._spans.filter((span) => span.id !== id);
if (rest.length === this._spans.length) return;
this._spans = rest;
this._onSpansChange();
}
public clearSpans(): void {
if (!this._spans.length) return;
this._spans = [];
this._onSpansChange();
}
public getSpans(): ITimelineSpan[] {
return this._spans;
}
public getSpan(id: string): ITimelineSpan | undefined {
return this._spans.find((span) => span.id === id);
}
/**
* Number of rows the current spans need, at least one when
* there are any.
*/
public get laneCount(): number {
return this._laneCount;
}
protected _onSpansChange(): void {
this._assignLanes();
this.events.dispatch(this.events.spanschange, this._spans);
}
protected _assignLanes(): void {
const laneEnds: number[] = [];
for (const span of [...this._spans].sort((left, right) => left.start.getTime() - right.start.getTime())) {
const start = span.start.getTime();
const end = Math.max(start, span.end.getTime());
let lane = laneEnds.findIndex((laneEnd) => laneEnd <= start);
if (lane < 0) {
lane = laneEnds.length;
}
laneEnds[lane] = end;
span.lane = lane;
}
this._laneCount = laneEnds.length;
}
public play() {
if (!this._requestAnimationFrameId) {
this._prevNow = window.performance.now();
this._animationFrameCallback();
this.events.dispatch(this.events.play, this);
}
}
public stop() {
if (this._requestAnimationFrameId) {
window.cancelAnimationFrame(this._requestAnimationFrameId);
this._requestAnimationFrameId = 0;
this.events.dispatch(this.events.stop, this);
}
}
public stopped() {
return this._requestAnimationFrameId == 0;
}
protected _animationFrameCallback() {
this._requestAnimationFrameId = window.requestAnimationFrame(() => {
this._frame();
this._animationFrameCallback();
});
}
protected _frame() {
let now = window.performance.now();
this.dt = now - this._prevNow;
this._prevNow = now;
if (this.driven) {
return;
}
if (this.clock) {
const time = this.clock();
if (time !== this.currentTime) {
this.current = new Date(time);
}
return;
}
this.current = new Date(this.currentTime + this.dt * this.multiplier);
// this._events.dispatch(this._events.tick, this._current);
}
public get range(): number {
return this._range;
}
public set(rangeStart: Date, rangeEnd: Date) {
if (rangeStart !== this._rangeStart || rangeEnd !== this._rangeEnd) {
this._rangeStart = rangeStart;
this._rangeEnd = rangeEnd;
this._range = this._rangeEnd.getTime() - this._rangeStart.getTime();
this.events.dispatch(this.events.change, rangeStart, rangeEnd);
}
}
public get current(): Date {
return this._current;
}
public get rangeStart(): Date {
return this._rangeStart;
}
public get rangeEnd(): Date {
return this._rangeEnd;
}
public get rangeStartTime(): number {
return this._rangeStart.getTime();
}
public get rangeEndTime(): number {
return this._rangeEnd.getTime();
}
public get currentTime(): number {
return this._current.getTime();
}
public set current(current: Date) {
if (current !== this._current) {
if (this._maxDate && current > this._maxDate) {
this._current = this._maxDate;
} else if (this._minDate && current < this._minDate) {
this._current = this._minDate;
} else {
this._current = current;
}
this.events.dispatch(this.events.current, this._current);
}
}
public set rangeStart(date: Date) {
if (date !== this._rangeStart) {
this._rangeStart = date;
this._range = this._rangeEnd.getTime() - this._rangeStart.getTime();
this.events.dispatch(this.events.change, date);
}
}
public set rangeEnd(date: Date) {
if (date !== this._rangeEnd) {
this._rangeEnd = date;
this._range = this._rangeEnd.getTime() - this._rangeStart.getTime();
this.events.dispatch(this.events.change, date);
}
}
}
function toSpan(params: ITimelineSpanParams): ITimelineSpan {
return {
id: params.id || `span_${TimelineModel.__spanCounter__++}`,
start: params.start,
end: params.end,
color: params.color || DEFAULT_SPAN_COLOR,
data: params.data,
lane: 0
};
}
export { TimelineModel };