control/timeline/TimelineControl.tstypescript
import { Dialog } from "../../ui/Dialog";
import { ToggleButton } from "../../ui/ToggleButton";
import { Control, type IControlParams } from "../Control";
import { TimelineView } from "./TimelineView";
import type { ITimelineSpan, ITimelineSpanParams, TimelineModel } from "./TimelineModel";
import { createEvents, type EventsHandler } from "../../Events";

type TimelineControlEventsList = [
    "visibility",
    "change",
    "setcurrent",
    "current",
    "play",
    "playback",
    "pause",
    "reset",
    "startdrag",
    "stopdrag",
    "startdragcurrent",
    "stopdragcurrent",
    "changelocaltime",
    "changelocaldatetime"
];

const TIMELINECONTROL_EVENTS: TimelineControlEventsList = [
    "visibility",
    "change",
    "setcurrent",
    "current",
    "play",
    "playback",
    "pause",
    "reset",
    "startdrag",
    "stopdrag",
    "startdragcurrent",
    "stopdragcurrent",
    "changelocaltime",
    "changelocaldatetime"
];

interface ITimelineControlParams extends IControlParams {
    name?: string;
    current?: Date;
    rangeStart?: Date;
    rangeEnd?: Date;
    use24HourClock?: boolean;
    template?: string;
}

function addHours(date: Date, hours: number): Date {
    const temp = new Date(date);
    temp.setHours(temp.getHours() + hours);
    return temp;
}

const ICON_BUTTON_SVG = `<?xml version="1.0" encoding="utf-8"?>
<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->
    <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
    <metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
    <g><path d="M500,10C229.4,10,10,229.4,10,500s219.4,490,490,490s490-219.4,490-490S770.6,10,500,10z M800.3,800.3c-39,39-84.5,69.7-135,91C613,913.5,557.4,924.7,500,924.7s-112.9-11.2-165.3-33.3c-50.5-21.3-95.9-52-135-91c-39-39-69.7-84.5-91-135C86.5,612.9,75.3,557.4,75.3,500s11.2-112.9,33.3-165.3c21.3-50.5,52-95.9,91-135c39-39,84.5-69.7,135-91C387.1,86.5,442.6,75.3,500,75.3s112.9,11.2,165.3,33.3c50.5,21.3,95.9,52,135,91c39,39,69.7,84.5,91,135c22.1,52.3,33.3,107.9,33.3,165.3s-11.2,112.9-33.3,165.3C869.9,715.8,839.3,761.2,800.3,800.3z"/><path d="M761.3,532.7H532.7V304c0-18.1-14.6-32.7-32.7-32.7s-32.7,14.6-32.7,32.7v261.3l0,0c0,18.1,14.6,32.7,32.7,32.7h261.3c18.1,0,32.7-14.6,32.7-32.7l0,0C794,547.3,779.4,532.7,761.3,532.7z"/></g>
</svg>`;

class TimelineControl extends Control {
    protected _timelineView: TimelineView;
    protected _toggleBtn: ToggleButton;
    protected _dialog: Dialog<null>;
    protected _defaultClockWasRunningBeforeDrag: boolean;

    public events: EventsHandler<TimelineControlEventsList>;

    constructor(options: ITimelineControlParams = {}) {
        super({
            name: "timeline",
            ...options
        });

        this.events = createEvents(TIMELINECONTROL_EVENTS);
        this._defaultClockWasRunningBeforeDrag = false;

        let currentDate = options.current || new Date();
        let startDate = options.rangeStart || addHours(currentDate, -12);
        let endDate = options.rangeEnd || addHours(currentDate, 12);

        this._timelineView = new TimelineView({
            rangeStart: startDate,
            rangeEnd: endDate,
            currentDate: currentDate,
            use24HourClock: options.use24HourClock,
            dateTemplate: options.template
        });

        this._toggleBtn = new ToggleButton({
            classList: ["og-map-button", "og-timeline_button"],
            icon: ICON_BUTTON_SVG,
            isActive: true
        });

        this._dialog = new Dialog({
            title: "Timeline",
            dock: "bottom",
            visible: true,
            resizable: true,
            useHide: true,
            top: 10,
            left: 60,
            width: 600,
            height: 128,
            minHeight: 128,
            maxHeight: 128
        });

        this._dialog.events.on("visibility", (v: boolean) => {
            this._toggleBtn.setActive(v);
            this.events.dispatch(this.events.visibility, v);
        });
    }

    /**
     * Returns timeline data model.
     * @public
     */
    public get model(): TimelineModel {
        return this._timelineView.model;
    }

    /** Date the Sun marker stands on. Setting it moves the marker without dispatching. */
    public get localDateTime(): Date {
        return this._timelineView.localDateTime;
    }

    public set localDateTime(date: Date) {
        this._timelineView.localDateTime = date;
    }

    /**
     * Marker label date format, e.g. "MM/dd/yyyy" or "hh:mm:ss.ms". Tokens: yyyy/yy, MMM,
     * MM/M, dd/d, hh/h, mm/m, ss/s, ms, a/A; case matters for M/m only. An empty template
     * keeps the adaptive scale format. am/pm follows {@link TimelineControl.use24HourClock}.
     */
    public get template(): string {
        return this._timelineView.dateTemplate;
    }

    public set template(template: string) {
        this._timelineView.dateTemplate = template;
    }

    /** Scale time notation: 24-hour, or 12-hour with am/pm. */
    public get use24HourClock(): boolean {
        return this._timelineView.use24HourClock;
    }

    public set use24HourClock(use24HourClock: boolean) {
        this._timelineView.use24HourClock = use24HourClock;
    }

    /** Lights the scene by the Sun marker, the civil clock time under the camera. */
    public applyLocalDateTime() {
        const sun = this.planet?.sun;

        if (!sun) return;

        sun.setLocalDateTime(this._timelineView.localDateTime);
        this.renderer && this.renderer.requestRedraw();
    }

    /**
     * Adds a colored time interval drawn on the scale, e.g. one per telemetry track.
     * Spans that overlap in time are placed on separate rows automatically, and the
     * rows share the scale height between them.
     * @public
     * @param {ITimelineSpanParams} params - Span start, end and color. An omitted id is generated.
     * @returns {ITimelineSpan} - Stored span, with its id and assigned row.
     */
    public addSpan(params: ITimelineSpanParams): ITimelineSpan {
        return this.model.addSpan(params);
    }

    /**
     * Adds several spans at once, keeping the ones already on the scale.
     * @public
     * @param {ITimelineSpanParams[]} params - Spans to add.
     * @returns {ITimelineSpan[]} - Stored spans, in the order they were given.
     */
    public addSpans(params: ITimelineSpanParams[]): ITimelineSpan[] {
        return this.model.addSpans(params);
    }

    /**
     * Replaces every span on the scale, e.g. to redraw the whole set after a source
     * was added or removed.
     * @public
     * @param {ITimelineSpanParams[]} params - Spans the scale is left with.
     * @returns {ITimelineSpan[]} - Stored spans, in the order they were given.
     */
    public setSpans(params: ITimelineSpanParams[]): ITimelineSpan[] {
        return this.model.setSpans(params);
    }

    /**
     * Replaces the time range, color and payload of a span, and reassigns the rows when
     * its time range moved.
     * @public
     * @param {string} id - Span id.
     * @param {ITimelineSpanParams} params - New span fields. Every field is replaced, the id is kept.
     * @returns {ITimelineSpan | undefined} - Updated span, or undefined when the id is unknown.
     */
    public updateSpan(id: string, params: ITimelineSpanParams): ITimelineSpan | undefined {
        return this.model.updateSpan(id, params);
    }

    /**
     * Removes a single span from the scale. An unknown id is ignored.
     * @public
     * @param {string} id - Span id.
     */
    public removeSpan(id: string): void {
        this.model.removeSpan(id);
    }

    /**
     * Removes every span from the scale.
     * @public
     */
    public clearSpans(): void {
        this.model.clearSpans();
    }

    /**
     * Returns the spans currently on the scale.
     * @public
     * @returns {ITimelineSpan[]} - Stored spans, each carrying its assigned row.
     */
    public getSpans(): ITimelineSpan[] {
        return this.model.getSpans();
    }

    /**
     * Returns a single span by its id.
     * @public
     * @param {string} id - Span id.
     * @returns {ITimelineSpan | undefined} - Stored span, or undefined when the id is unknown.
     */
    public getSpan(id: string): ITimelineSpan | undefined {
        return this.model.getSpan(id);
    }

    public override oninit() {
        let $container = this.renderer!.getUIContainer();
        const defaultClock = this.renderer!.handler.defaultClock;

        this._toggleBtn.appendTo(this.renderer!.topLeftContainer());
        this._dialog.appendTo($container);
        this._dialog.events.on("visibility", (v: boolean) => {
            if (v) {
                this._dialog.positionNearElementOnFirstOpen(this._toggleBtn.el, this.renderer!.getUIContainer());
            }
        });

        this._toggleBtn.events.on("change", (isActive: boolean) => {
            this._dialog.setVisibility(isActive);
            if (isActive) {
                this._timelineView.resize();
            }
        });

        this._timelineView.appendTo(this._dialog.container!);

        let sun = this.planet?.sun;
        let initialDateTime: Date | null = null;

        if (sun) {
            if (sun.dateTime) {
                initialDateTime = sun.dateTime;
            } else if (sun.localDateTime) {
                initialDateTime = sun.localDateTime;
            }
        }

        if (sun && initialDateTime) {
            let halfRange = this._timelineView.model.range * 0.5;
            this._timelineView.model.set(
                new Date(initialDateTime.getTime() - halfRange),
                new Date(initialDateTime.getTime() + halfRange)
            );
            this._timelineView.model.current = initialDateTime;

            if (!sun.dateTime) {
                this._timelineView.localDateTime = initialDateTime;
                this._timelineView.localTime = true;
            }
        }

        defaultClock.multiplier = this._timelineView.model.multiplier;
        defaultClock.setDate(this._timelineView.model.current);

        if (this._timelineView.model.stopped()) {
            defaultClock.stop();
        } else {
            defaultClock.start();
        }

        this._timelineView.events.on("setcurrent", (d: Date) => {
            this.renderer && defaultClock.setDate(d);

            if (!this._timelineView.localTime) {
                this.planet?.sun?.setDateTime(d);
            }

            this.events.dispatch(this.events.setcurrent, d);
        });

        this._timelineView.events.on("changelocaltime", (isActive: boolean) => {
            let sun = this.planet?.sun;
            if (sun) {
                if (isActive) {
                    this.applyLocalDateTime();
                } else {
                    sun.setDateTime(this._timelineView.model.current);
                    this.renderer && this.renderer.requestRedraw();
                }
            }
            this.events.dispatch(this.events.changelocaltime, isActive);
        });

        this._timelineView.events.on("changelocaldatetime", (d: Date) => {
            this.applyLocalDateTime();
            this.events.dispatch(this.events.changelocaldatetime, d);
        });

        this._timelineView.model.events.on("change", (...args: unknown[]) => {
            this.events.dispatch(this.events.change, ...args);
        });

        this._timelineView.model.events.on("current", (d: Date) => {
            this.events.dispatch(this.events.current, d);
        });

        this._timelineView.events.on("play", (...args: unknown[]) => {
            defaultClock.multiplier = this._timelineView.model.multiplier;
            defaultClock.start();
            this.events.dispatch(this.events.play, ...args);
        });

        this._timelineView.events.on("playback", (...args: unknown[]) => {
            defaultClock.multiplier = this._timelineView.model.multiplier;
            defaultClock.start();
            this.events.dispatch(this.events.playback, ...args);
        });

        this._timelineView.events.on("pause", (...args: unknown[]) => {
            defaultClock.stop();
            this.events.dispatch(this.events.pause, ...args);
        });

        this._timelineView.events.on("reset", (...args: unknown[]) => {
            defaultClock.stop();
            this.events.dispatch(this.events.reset, ...args);
        });

        this._timelineView.events.on("startdrag", (e: Event) => {
            this._defaultClockWasRunningBeforeDrag = !this._timelineView.model.stopped();
            defaultClock.stop();
            this.planet?.sun!.stop();
            this.renderer && this.renderer.controls.navigation.deactivate();
            this.events.dispatch(this.events.startdrag, e);
        });

        this._timelineView.events.on("stopdrag", (d: Date) => {
            if (this._defaultClockWasRunningBeforeDrag) {
                defaultClock.start();
            }
            this.renderer && this.renderer.controls.navigation.activate();
            this.events.dispatch(this.events.stopdrag, d);
        });

        this._timelineView.events.on("startdragcurrent", (e: Event) => {
            this._defaultClockWasRunningBeforeDrag = !this._timelineView.model.stopped();
            defaultClock.stop();
            this.planet?.sun!.stop();
            this.renderer && this.renderer.controls.navigation.deactivate();
            this.events.dispatch(this.events.startdragcurrent, e);
        });

        this._timelineView.events.on("stopdragcurrent", (d: Date) => {
            if (this._defaultClockWasRunningBeforeDrag) {
                defaultClock.start();
            }
            this.renderer && this.renderer.controls.navigation.activate();
            this.events.dispatch(this.events.stopdragcurrent, d);
        });
    }
}

export { TimelineControl };