control/LayerAnimation.tstypescript
/*
 * Copyright 2026 Michael Gevlich
 * 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 { type EventsHandler, createEvents } from "../Events";
import { Layer } from "../layer/Layer";
import { Control, type IControlParams } from "./Control";

type LayerAnimationEventsList = ["change", "idle", "play", "pause", "stop"];

const LAYERANIMATION_EVENTS: LayerAnimationEventsList = ["change", "idle", "play", "pause", "stop"];

interface ILayerAnimationParams extends IControlParams {
    layers?: Layer;
    playInterval?: number;
    frameSize?: number;
    repeat?: boolean;
    skipTimeout?: number;
}

export class LayerAnimation extends Control {
    public events: EventsHandler<LayerAnimationEventsList>;
    protected _layersArr: Layer[];
    protected _currentIndex: number;
    protected _playInterval: number;
    protected _playIntervalHandler: any;
    protected _playIndex: number;
    protected _frameSize: number;
    public repeat: boolean;
    public skipTimeout: number;
    protected _timeoutStart: number;
    protected _currVisibleIndex: number = 0;

    constructor(options: ILayerAnimationParams = {}) {
        super(options);
        this.events = createEvents(LAYERANIMATION_EVENTS);
        this._name = options.name || `layerAnimation-${this.__id}`;
        this._layersArr = options.layers ? ([] as Layer[]).concat(options.layers) : [];
        this._currentIndex = -1;
        this._playInterval = options.playInterval || 120;
        this._playIntervalHandler = -1;
        this._playIndex = 0;
        this._frameSize = options.frameSize || 50;
        this.repeat = options.repeat != undefined ? options.repeat : true;
        this.skipTimeout = options.skipTimeout || 5000;
        this._timeoutStart = 0;
    }

    protected _onViewchange = () => {
        this._timeoutStart = performance.now();
    };

    protected _getFramesNum(): number {
        return Math.ceil(this._layersArr.length / this._frameSize);
    }

    protected _setFrame(frameIndex: number) {
        for (let i = 0, len = this._getFramesNum(); i < len; i++) {
            if (i !== frameIndex) {
                this._removeFrameFromPlanet(i);
            } else {
                this._appendFrameToPlanet(i);
            }
        }
    }

    protected _getFrameIndex(layerIndex: number): number {
        return Math.floor(layerIndex / this._frameSize);
    }

    protected _appendFrameToPlanet(frameIndex: number) {
        if (this.planet) {
            let minIndex = frameIndex * this._frameSize;
            let maxIndex = minIndex + this._frameSize;
            for (
                let i = minIndex, len = maxIndex > this._layersArr.length ? this._layersArr.length : maxIndex;
                i < len;
                i++
            ) {
                this.planet.addLayer(this._layersArr[i]);
            }
        }
    }

    protected _removeFrameFromPlanet(frameIndex: number) {
        if (this.planet) {
            let minIndex = frameIndex * this._frameSize;
            let maxIndex = minIndex + this._frameSize;
            for (
                let i = minIndex, len = maxIndex > this._layersArr.length ? this._layersArr.length : maxIndex;
                i < len;
                i++
            ) {
                this._layersArr[i].abortLoading();
                this._layersArr[i].remove();
                this._layersArr[i].setVisibility(false);
            }
        }
    }

    public override oninit() {
        super.oninit();
        this.onactivate();
        this._initLayers();
        this.planet!.events.on("layerloadend", this._onLayerLoadend);
        this._setCurrentIndexAsync(0, false, true);
    }

    public override onactivate() {
        super.onactivate();
        this.planet!.camera.events.on("viewchange", this._onViewchange);
        this.planet!.renderer!.handler.events.on("visibilitychange", this._onVisibilityChange);
    }

    public override ondeactivate() {
        super.ondeactivate();
        this.planet!.camera.events.off("viewchange", this._onViewchange);

        for (let i = 0; i < this._layersArr.length; i++) {
            this._layersArr[i].setVisibility(false);
        }

        this.planet!.events.off("layerloadend", this._onLayerLoadend);
        this.planet!.renderer!.handler.events.off("visibilitychange", this._onVisibilityChange);
    }

    protected _onVisibilityChange = (isVisible: boolean) => {
        if (!isVisible) {
            this.pause();
        }
    };

    public clear() {
        this.stop();
        this._currentIndex = -1;
        this._currVisibleIndex = -1;
        let layersToRemove = this._layersArr;
        this._layersArr = [];
        for (let i = 0; i < layersToRemove.length; i++) {
            layersToRemove[i].remove();
        }
    }

    protected _initLayers() {
        if (this.planet) {
            for (let i = 0, len = this._layersArr.length; i < len; i++) {
                let li = this._layersArr[i];
                li.setVisibility(false);
                li.setBaseLayer(false);
                li.opacity = 0.0;
                li.waitForParentMaterial = false;
            }
            this._appendFrameToPlanet(0);
        }
    }

    public setLayers(layers: Layer[]) {
        this.clear();
        this._layersArr = [].concat(layers as any);
        this._initLayers();
    }

    public appendLayer(layer: Layer) {
        this._layersArr.push(layer);
        layer.setVisibility(false);
        layer.setBaseLayer(false);
        layer.opacity = 0.0;
        //check current frame
        this.planet?.addLayer(layer);
    }

    /**
     * warning: Use XYZ.isIdle in requestAnimationFrame(after setVisibility)
     * @returns Returns true if current layer is idle
     */
    public get isIdle(): boolean {
        let currLayer = this._layersArr[this._currentIndex];
        return (currLayer && currLayer.isIdle) || !currLayer;
    }

    public get playInterval() {
        return this._playInterval;
    }

    public set playInterval(val: number) {
        if (val !== this._playInterval) {
            this._playInterval = val;
            if (this.isPlaying) {
                this.pause();
                this.play();
            }
        }
    }

    public get isPlaying(): boolean {
        return this._playIntervalHandler !== -1;
    }

    public get layers(): Layer[] {
        return this._layersArr;
    }

    protected _checkEnd() {
        if (this._playIndex > this._layersArr.length) {
            if (this.repeat) {
                this._playIndex = 0;
            } else {
                this.pause();
            }
        }
    }

    public play() {
        if (!this.isPlaying) {
            if (this._currentIndex >= this._layersArr.length - 1) {
                this.stop();
            }

            this._timeoutStart = performance.now();

            this._playIntervalHandler = setInterval(() => {
                this._checkEnd();
                this._setCurrentIndexAsync(this._playIndex, false, false);

                requestAnimationFrame(() => {
                    if (this.isIdle || performance.now() - this._timeoutStart > this.skipTimeout) {
                        this._playIndex++;
                        this._timeoutStart = performance.now();
                    }
                });
            }, this._playInterval);

            this.events.dispatch(this.events.play);
        }
    }

    public stop() {
        if (this._playIndex > 0) {
            this._clearInterval();
            this._playIndex = 0;
            this.setCurrentIndex(0);
            this.events.dispatch(this.events.stop);
        }
    }

    public pause() {
        if (this.isPlaying) {
            this._clearInterval();
            this.events.dispatch(this.events.pause);
        }
    }

    protected _clearInterval() {
        clearInterval(this._playIntervalHandler);
        this._playIntervalHandler = -1;
    }

    /**
     * Waiting for the current index layer loadend and make it non-transparent,
     * and make prev layer transparent, also check previous frame index to clean up.
     */
    private _onLayerLoadend = (layer: Layer) => {
        let currLayer = this._layersArr[this._currentIndex];

        if (currLayer && currLayer.isEqual(layer)) {
            // BRUTE
            let currFrame = this._getFrameIndex(this._currentIndex);
            let from = currFrame * this._frameSize,
                to = this._currentIndex;
            for (let i = from; i < to; i++) {
                let li = this._layersArr[i];
                li.opacity = 0;
                li.setVisibility(false);
            }

            // * Make CURRENT Layer VISIBLE *
            currLayer.opacity = 1.0;

            let currVisibleLayer = this._layersArr[this._currVisibleIndex];
            if (currVisibleLayer) {
                currVisibleLayer.opacity = 0.0;
                currVisibleLayer.setVisibility(false);

                // If frame is changed - remove it from the planet
                let prevFrame = this._getFrameIndex(this._currVisibleIndex);
                if (this._getFrameIndex(this._currentIndex) !== prevFrame) {
                    this._removeFrameFromPlanet(prevFrame);
                }
            }

            this.events.dispatch(this.events.idle, currLayer);
        }
    };

    /**
     * Function sets layer index visible.
     * @param {number} index
     * @param {boolean} [stopPropagation=false]
     */
    setCurrentIndex(index: number, stopPropagation = false) {
        this._setCurrentIndexAsync(index, true, stopPropagation);
    }

    /**
     * Function sets layer index visible. If the layer is idle (all visible tiles loaded), sets opacity to one,
     * otherwise to ZERO it means that when all visible tiles will be loaded the opacity becomes ONE. So, previous
     * layer remains not transparent (opacity = 1) till current layer is loading.
     */
    protected _setCurrentIndexAsync(index: number, forceVisibility: boolean = false, stopPropagation: boolean = false) {
        if (index != this._currentIndex && index >= 0 && index < this._layersArr.length) {
            let prevCurrIndex = this._currentIndex;
            this._currentIndex = index;
            this._playIndex = index;

            let prevCurrFrame = this._getFrameIndex(prevCurrIndex);
            let currFrame = this._getFrameIndex(this._currentIndex);

            let prevCurrLayer = this._layersArr[prevCurrIndex],
                currLayer = this._layersArr[index];

            let frameChanged = currFrame != prevCurrFrame;
            if (frameChanged) {
                this._appendFrameToPlanet(currFrame);
            }

            if (prevCurrLayer) {
                if (prevCurrLayer.isIdle) {
                    this._currVisibleIndex = prevCurrIndex;
                } else {
                    prevCurrLayer.opacity = 0;
                    prevCurrLayer.setVisibility(false);
                }
            }

            if (currLayer) {
                currLayer.opacity = 0.0;
                currLayer.setVisibility(true);

                requestAnimationFrame(() => {
                    if (currLayer.isIdle || forceVisibility) {
                        currLayer.opacity = 1.0;

                        // If frame is changed - remove it from the planet
                        if (frameChanged) {
                            this._removeFrameFromPlanet(prevCurrFrame);
                        }

                        if (prevCurrLayer) {
                            prevCurrLayer.opacity = 0.0;
                            prevCurrLayer.setVisibility(false);
                        }

                        let currVisibleLayer = this._layersArr[this._currVisibleIndex];
                        if (currVisibleLayer) {
                            currVisibleLayer.opacity = 0.0;
                            currVisibleLayer.setVisibility(false);
                        }
                    }
                });

                if (!stopPropagation) {
                    this.events.dispatch(this.events.change, this._currentIndex, prevCurrIndex);
                }
            }
        }
    }
}