Source: api.js

import * as THREE from "three";
import {notify, exportGLTF, saveString} from "./utils.js";
import {HTMLMesh} from "../libs/interactive/HTMLMesh.js";
import {Lut, ColorMapKeywords} from "../libs/math/Lut.js";

/*global MathJax*/

class Api {
    /**
     * An api object is included in the global scope so that it can be called
     * from the developer console.
     * @param {THREE.Camera} camera
     * @param {THREE.Scene} scene
     * @param {THREE.Renderer} renderer
     * @param {MapControls} controls
     * @param {PatchManager} patchManager
     */
    constructor(camera, scene, orthoCamera, uiScene, renderer, controls, patchManager) {
        this.camera = camera;
        this.scene = scene;
        this.orthoCamera = orthoCamera;
        this.uiScene = uiScene;
        this.renderer = renderer;
        this.controls = controls;
        this.patchManager = patchManager;
        this.timelineYearLabel = document.getElementById("timelineYearLabel");

        const data = {};
        for (const v in ColorMapKeywords) {
            data[v] = v;
        }

        // Populate color map selects
        for (const s of ["#stemColorMapSelect", "#crownColorMapSelect"]) {
            // eslint-disable-next-line no-undef
            const select = $(s).data("select");
            select.data(data);
        }

        [
            "ColorSelect",
            "ColorMapSelect",
            "ColorLegendLabel",
            "ColorLegendXPos",
            "ColorLegendYPos",
            "ColorLegendTicks",
            "ColorLegendDecimals",
            "ColorLegendMin",
            "ColorLegendMinAuto",
            "ColorLegendMax",
            "ColorLegendMaxAuto",
            "ColorLegendVertical",
            "ColorLegendScientific"
        ].forEach(idSuffix=> {
            document.getElementById("stem"+idSuffix).addEventListener("change", ()=>this.setStemColorMapFromUI());
            document.getElementById("crown"+idSuffix).addEventListener("change", ()=>this.setCrownColorMapFromUI());
        });
    }

    render() {
        this.renderer.autoClear = true;

        this.renderer.render(this.scene, this.camera);

        // Prevent canvas from being erased with next render call
        this.renderer.autoClear = false;

        this.renderer.render(this.uiScene, this.orthoCamera);
    }

    /**
     * Enable or disable crown opacity according to the LAI property, such that
     * opacity = 1-exp(-0.5*LAI) and 0 == fully transparent.
     * @param {boolean} value Set to true to enable, false to disable
     */
    setLaiOpacity(value) {
        this.patchManager.laiOpacityEnabled = value;
        this.redraw();
    }

    setStemColorMapFromUI() {
        const getVal = id => document.getElementById(id).value;
        const checked = id => document.getElementById(id).checked;
        const colorMapSelect = document.getElementById("stemColorMapSelect");

        this.setStemColorMap(
            getVal("stemColorSelect"),
            getVal("stemColorMapSelect"),
            new THREE.Vector2(
                Number.parseFloat(getVal("stemColorLegendXPos")),
                Number.parseFloat(getVal("stemColorLegendYPos"))
            ),
            {
                "title": getVal("stemColorLegendLabel"),
                "ticks": Number.parseInt(getVal("stemColorLegendTicks")),
                "decimal" : Number.parseInt(getVal("stemColorLegendDecimals")),
                "notation": checked("stemColorLegendScientific") ? "scientific" : undefined
            },
            checked("stemColorLegendVertical"),
            checked("stemColorLegendMinAuto") ? undefined : Number.parseFloat(getVal("stemColorLegendMin")),
            checked("stemColorLegendMaxAuto") ? undefined : Number.parseFloat(getVal("stemColorLegendMax")),
        );
        colorMapSelect.disabled = getVal("stemColorSelect") === "PFT";
    }

    setCrownColorMapFromUI() {
        const getVal = id => document.getElementById(id).value;
        const checked = id => document.getElementById(id).checked;
        const colorMapSelect = document.getElementById("crownColorMapSelect");

        this.setCrownColorMap(
            getVal("crownColorSelect"),
            getVal("crownColorMapSelect"),
            new THREE.Vector2(
                Number.parseFloat(getVal("crownColorLegendXPos")),
                Number.parseFloat(getVal("crownColorLegendYPos"))
            ),
            {
                "title": getVal("crownColorLegendLabel"),
                "ticks": Number.parseInt(getVal("crownColorLegendTicks")),
                "decimal" : Number.parseInt(getVal("crownColorLegendDecimals")),
                "notation": checked("crownColorLegendScientific") ? "scientific" : undefined
            },
            checked("crownColorLegendVertical"),
            checked("crownColorLegendMinAuto") ? undefined : Number.parseFloat(getVal("crownColorLegendMin")),
            checked("crownColorLegendMaxAuto") ? undefined : Number.parseFloat(getVal("crownColorLegendMax")),
        );
        colorMapSelect.disabled = getVal("crownColorSelect") === "PFT";
    }

    /**
     * Color stems or crowns by a data attribute.
     * @param {string} target Either "stem" or "crown"
     * @param {string} attribute Data column from the input file, e.g. "Diam"
     * @param {string} colorMap A matplotlib color map name
     * @param {THREE.Vector2} legendPosition Position of the legend, where (0,0) is the center of the canvas and one unit is the horisontal distance from the center to the canvas edge.
     * @param {{ticks: number, decimal: number, title: string}} labelParams Legend parameters. "ticks" controls number of ticks, "decimal", the number of decimals shown, "title" the legend title (enclose LaTeX math expressions in $-signs). Set notation = "scientific" for exponentials.
     * @param {number} minValue Minimum value for the color map (leave undefined to calculate automatically)
     * @param {number} maxValue Minimum value for the color map (leave undefined to calculate automatically)
     */
    setColorMap(
        target, attribute, colorMap = "coolwarm",
        legendPosition = new THREE.Vector2(),
        labelParams = {"ticks": 5},
        verticalLegend = false,
        minValue,
        maxValue
    ) {
        if (target === undefined) {
            console.error(`Target ${target} unknown, must be "stem" or "crown"!`);
        }
        if (attribute === undefined || attribute === "PFT") {
            this.patchManager[target+"ColorMap"] = undefined;
            this.uiScene.remove(this[target+"LegendGroup"]);
        } else {
            const lut = this.calcLut(attribute, colorMap, minValue, maxValue);
            this.patchManager[target+"ColorMap"] = {
                lut: lut,
                attribute: attribute
            };

            this.uiScene.remove(this[target+"LegendGroup"]);
            this[target+"LegendGroup"] = new THREE.Group();
            this.uiScene.add(this[target+"LegendGroup"]);

            // Set normalised position of the legend
            this[target+"LegendGroup"].position.x = legendPosition.x * this.orthoCamera.right;
            this[target+"LegendGroup"].position.y = legendPosition.y * this.orthoCamera.right;

            if (labelParams.title === undefined || labelParams.title === "") {
                labelParams.title = `${attribute} (${target} color)`;
            }

            let legend;
            if (verticalLegend) {
                legend = lut.setLegendOn();
            } else {
                legend = lut.setLegendOn({layout: "horizontal"});
            }
            this[target+"LegendGroup"].add(legend);
            let labels = lut.setLegendLabels(labelParams, undefined, ()=>this.render());
            this[target+"LegendGroup"].add(labels["title"]);
            for (let i = 0; i < Object.keys(labels["ticks"]).length; i++) {
                this[target+"LegendGroup"].add(labels["ticks"][i]);
                this[target+"LegendGroup"].add(labels["lines"][i]);
            }

            this.uiScene.addEventListener("updateColor", function() {
                lut.updateCanvas(legend.material.map.image);
                legend.material.map.needsUpdate = true;
            });
        }
        this.redraw();
    }

    /**
     * Color stems by a data attribute. Call without arguments to clear.
     * @param {string} attribute Data column from the input file, e.g. "Diam"
     * @param {string} colorMap A matplotlib color map name
     * @param {THREE.Vector2} legendPosition Position of the legend, where (0,0) is the center of the canvas and one unit is the horisontal distance from the center to the canvas edge.
     * @param {{ticks: number, decimal: number, title: string}} labelParams Legend parameters. "ticks" controls number of ticks, "decimal", the number of decimals shown, "title" the legend title (enclose LaTeX math expressions in $-signs). Set notation = "scientific" for exponentials.
     * @param {boolean} verticalLegend If set to true, the legend will be vertical, otherwise horisontal.
     * @param {number} minValue Minimum value for the color map (leave undefined to calculate automatically)
     * @param {number} maxValue Minimum value for the color map (leave undefined to calculate automatically)
     */
    setStemColorMap(
        attribute, colorMap="coolwarm",
        legendPosition=new THREE.Vector2(),
        labelParams = {"ticks": 5},
        verticalLegend = false,
        minValue,
        maxValue
    ) {
        this.setColorMap("stem", attribute, colorMap, legendPosition, labelParams, verticalLegend, minValue, maxValue);
    }

    /**
     * Color crowns by a data attribute. Call without arguments to clear.
     * @param {string} attribute Data column from the input file, e.g. "Height"
     * @param {string} colorMap A matplotlib color map name
     * @param {THREE.Vector2} legendPosition Position of the legend, where (0,0) is the center of the canvas and one unit is the horisontal distance from the center to the canvas edge.
     * @param {{ticks: number, decimal: number, title: string}} labelParams Legend parameters. "ticks" controls number of ticks, "decimal", the number of decimals shown, "title" the legend title (enclose LaTeX math expressions in $-signs). Set notation = "scientific" for exponentials.
     * @param {boolean} verticalLegend If set to true, the legend will be vertical, otherwise horisontal.
     * @param {number} minValue Minimum value for the color map (leave undefined to calculate automatically)
     * @param {number} maxValue Minimum value for the color map (leave undefined to calculate automatically)
     */
    setCrownColorMap(attribute, colorMap="coolwarm",
        legendPosition=new THREE.Vector2(),
        labelParams = {"ticks": 5},
        verticalLegend = false,
        minValue,
        maxValue
    ) {
        this.setColorMap("crown", attribute, colorMap, legendPosition, labelParams, verticalLegend, minValue, maxValue);
    }

    calcLut(attribute, colorMap="rainbow", min, max) {
        if (max === undefined || min === undefined) {
            let calcMax = -Infinity;
            let calcMin  = Infinity;
            for (const patch of this.patchManager.patches.values()) {
                for (const cohort of patch.cohorts.values()) {
                    for (const t of cohort.timeSteps.values())  {
                        if (max === undefined) {
                            calcMax = Math.max(t[attribute], calcMax);
                        }
                        if (min === undefined) {
                            calcMin = Math.min(t[attribute], calcMin);
                        }
                    }
                }
            }
            if (max === undefined) {
                max = calcMax;
            }
            if (min === undefined) {
                min = calcMin;
            }
        }

        // Lut cannot define color if they are the same
        if (max === min) {
            console.warn(`Max (${max}) and min (${min}) value for ${attribute} attribute are equal!`);
            max++;
            min--;
        }

        const lut = new Lut(colorMap);
        lut.setMin(min);
        lut.setMax(max);

        return lut;
    }

    /**
     * Set the number of times branches should split.
     * Be aware that high values will make the visualisation
     * really slow and might cause the WebGL context to crash.
     * @param {number} levels Number of divisions per tree branch
     */
    setDetailedTreeFactor(levels) {
        this.patchManager.detailedTreeFactor = levels;
        this.redraw();
    }

    /**
     * Toggle between detailed and simple tree visualisation
     * @param {boolean} detailed True for detailed trees
     */
    setTreeDetail(detailed) {
        this.patchManager.detailedTrees = detailed;
        this.redraw();
    }

    /**
     * Toggle between constant patch heights for each patch, or a smooth
     * interpolated surface connecting the patches.
     * @param {boolean} smooth True for interpolated terrain
     */
    setTerrainSmoothness(smooth) {
        this.patchManager.smoothTerrain = smooth;
        this.redraw();
    }

    /** Redraw the patches */
    redraw() {
        this.patchManager.setYear(this.patchManager.currentYear);
        this.render();
    }

    /**
     * Updates the distance between patches
     * @param {number} patchMargins A factor to distance patches from each
     * other. A value of 1 means no margin. A value of 1.2 means 20% margin.
     */
    updateMargins(patchMargins) {
        this.patchManager.updateMargins(patchMargins);
        this.render();
    }

    /**
     * Go to the previous year (if any)
     */
    prevYear() {
        this.patchManager.prevYear();
        this.timelineYearLabel.innerHTML = this.patchManager.currentYear;
        this.render();
    }

    /**
     * Go to the next year (if any)
     */
    nextYear() {
        this.patchManager.nextYear();
        this.timelineYearLabel.innerHTML = this.patchManager.currentYear;
        this.render();
    }


    /**
     * Starts going through the trajectory
     * @return {function(): void} Function to stop the playback.
     */
    playTrajectory() {
        let stop = false;
        const button = document.getElementById("trajectoryStartButton");
        button.innerHTML = "<span class='mif-stop icon'></span>";
        const stopFunction = ()=>{
            stop = true;
        };

        button.onclick = stopFunction;

        const lastYear = Math.max(...this.patchManager.years);
        const step = () => {
            if (this.patchManager.currentYear >= lastYear || stop) {
                button.onclick = ()=>{this.playTrajectory();};
                // eslint-disable-next-line quotes
                button.innerHTML = "<span class='mif-play icon'></span>";
            } else {
                this.nextYear();
                requestAnimationFrame(step);
            }
        };
        step();
        return stopFunction;
    }

    /**
     * Scales the HTML canvas (used for higher resolution
     * in image and video export).
     * You are meant to scale it back again when the export
     * is done, otherwise things will look odd.
     * @param {number} scalingFactor Multiplier to scale the canvas with
     */
    scaleCanvas(scalingFactor=2) {
        const canvas = this.renderer.domElement;
        const width = canvas.width;
        const height = canvas.height;
        canvas.width = width*scalingFactor;
        canvas.height = height*scalingFactor;
        this.camera.updateProjectionMatrix();
        this.renderer.setSize(canvas.width, canvas.height);
        this.render();
    }

    /**
     * Export a CSV file containing all the data
     * (including individual tree coordinates)
     * @param {string} delimiter Set it to "," for CSV, "\t" for TSV, etc.
     */
    exportCSV(delimiter = ",") {
        const data = [];
        for (const patch of this.patchManager.patches.values()) {
            for (const cohort of patch.cohorts.values()) {
                for (const [year, timestep] of cohort.timeSteps.entries()) {
                    for (const p of timestep.positions.values()) {
                        const d = {
                            x: p.x.toFixed(3),
                            y: p.y.toFixed(3),
                            z: this.patchManager.detailedTerrainMap(p, patch),
                            TID: p.occupyingInstance,
                            ...timestep,
                            ...this.patchManager.yearData.get(year)
                        };
                        delete d.positions;
                        data.push(d);
                    }
                }
            }
        }

        // Sort chronologically
        data.sort((a, b) => a.Year - b.Year);

        const keys = Object.keys(data[0]);
        const header = keys.join(delimiter);
        const lines = [header, ...data.map(
            d=>keys.map(k=>d[k]).join(delimiter)
        )];

        saveString(
            lines.join("\n"),
            this.patchManager.datasetName+".csv"
        );
    }

    /**
     * Export the scene as a glTF/glb 3D shape file.
     * @param {THREE.Scene} scene Scene to export
     * @param {boolean} binary Set to true for binary glb or false for plaintext glTF
     * @param {string} name Name for the file @default this.patchManager.datasetName
     */
    exportGLTF(scene=this.scene, binary=false, name=this.patchManager.datasetName) {
        exportGLTF(scene, binary, name);
    }

    /**
     * Export image of the current view
     * @param {number} scaleFactor Multiplier to for higher resolution
     */
    exportImage(scaleFactor) {
        if (scaleFactor === undefined) {
            scaleFactor = parseFloat((document.getElementById("exportImageScalingFactor")).value);
        }

        let saveImage = () => {
            this.renderer.domElement.toBlob(blob => {
                var a = document.createElement("a");
                var url = URL.createObjectURL(blob);
                a.href = url;
                a.download = this.patchManager.datasetName+".png";
                setTimeout(() => a.click(), 10);
            }, "image/png", 1.0);
        };

        // First scale the canvas with the provided factor, then scale it back.
        new Promise(resolve => {
            this.scaleCanvas(scaleFactor);
            resolve("success");
        }).then(() => {
            try {
                saveImage();
            } catch (error) {
                notify("Canvas is too large to save, please try a smaller scaling factor", "alert");
            }
            this.scaleCanvas(1/scaleFactor);
        });
    }

    /**
     * Toggle the PFT legend on or off
     * @param {boolean} visible
     */
    setPFTLegendVisibility(visible) {
        if (visible) {
            this.showPFTLegend();
        } else {
            this.hidePFTLegend();
        }
    }

    /**
     * Hide the PFT legend
     */
    hidePFTLegend() {
        this.camera.remove(this.legend);
        this.legend = undefined;
        this.render();
    }

    /**
     * Show a PFT legend in the scene. An HTML mesh is used instead of a normal
     * HTML element so that the legend can be visible in exported images
     * @param {THREE.Vector2} position Legend position (where (0,0) is the center of the screen and (1,1) is the upper-left corner).
     * @param {number} scale Scale down (and use a large font size) to gain resolution
     * @param {number} fontSize Increase (and use a smaller scale) to gain resolution
     * @param {number} distance Distance from camera
     * @param {number} margin Margin from edge
     */
    showPFTLegend(legendPosition = new THREE.Vector2(), scale = 0.25, fontSize="4em", distance = 1) {
        const rows = this.patchManager.pftConstants.map(
            c=>`<tr>
                <td>${MathJax.tex2mml(`\\text{${c.name}}`, {display: false})}</td>
                <td style="width: 150px; background:#${c.color.getHexString()}"></td>
            </tr>`
        ).filter((c,i)=>this.patchManager.usedPFTs.has(i));
        const legendDiv = document.createElement("table");
        legendDiv.style.fontSize = fontSize;
        legendDiv.style.borderRadius = "15px";
        legendDiv.classList.add("table", "cell-border");
        legendDiv.innerHTML = `
            <tbody>
                ${rows}
            </tbody>`;

        // I have no idea why this is needed
        // but some commas are appended at the end
        while (legendDiv.innerHTML.endsWith(",")) {
            legendDiv.innerHTML = legendDiv.innerHTML.slice(0, -1);
        }

        legendDiv.style.backgroundColor = "white";//"transparent";
        legendDiv.style.position = "fixed";
        legendDiv.style.bottom = 0;
        legendDiv.style.width = "auto";
        document.body.appendChild(legendDiv);

        // Remove any old legend mesh and create a new
        this.camera.remove(this.legend);
        this.legend = new HTMLMesh(legendDiv);

        // Remove the div from the body
        // It was only needed for creating the mesh
        legendDiv.remove();

        // Scale down mesh to gain resolution
        this.legend.scale.multiplyScalar(scale);
        this.camera.add(this.legend);
        this.legend.position.z = -distance;

        // Position to the left of the view
        const position = ()=>{
            const verticalFOV = THREE.MathUtils.degToRad(this.camera.fov);
            const visibleHeight = 2 * Math.tan(verticalFOV / 2) * distance;
            const visibleWidth = visibleHeight * this.camera.aspect;
            this.legend.position.x = legendPosition.x * visibleWidth/2;
            this.legend.position.y = legendPosition.y * visibleHeight/2;
            this.render();
        };
        position();

        // Update position on window resize
        window.addEventListener("resize", position);
    }

    /**
     * Show window with PFT editor
     */
    showPFTEditor() {
        const rows = this.patchManager.pftConstants.map(
            (c,i)=>`<tr>
                <td>${i}</td>
                <td>
                    <input onchange="api.patchManager.pftConstants[${i}].name = this.value; api.redraw()" type="text" value="${c.name}">
                </td>
                <td>
                    <select onchange="api.patchManager.pftConstants[${i}].geometry = this.value; api.redraw()">
                        <option value="cone" ${c.geometry === "cone" ? "selected" : ""}>cone</option>
                        <option value="sphere" ${c.geometry === "sphere" ? "selected" : ""}>sphere</option>
                    </select>
                </td>
                <td>
                    <input onchange="api.patchManager.pftConstants[${i}].detailMesh = this.value; api.redraw()" type="text" value="${c.detailMesh}"/>
                </td>
                <td>
                    <input onchange="api.patchManager.pftConstants[${i}].color.set(this.value); api.redraw()" type="color" value="#${c.color.getHexString()}"/>
                </td>
            </tr>
            `
        ).filter(
            // Only list PFTs used
            (c,i)=>this.patchManager.usedPFTs.has(i)
        );
        // eslint-disable-next-line no-undef
        Metro.window.create({
            title: "PFT settings",
            place: "center",
            icon: "<span class='mif-cog'></span>",
            content: `
<table class="table striped">
    <thead>
        <tr>
            <th>PFT</th>
            <th>Name</th>
            <th>Simple shape</th>
            <th>Detailed shape</th>
            <th>Color</th>
        </tr>
    </thead>
    <tbody>
    ${rows}
    </tbody>
</table>
`
        });
    }

    showVideoExportWindow() {
        // eslint-disable-next-line no-undef
        Metro.window.create({
            title: "Export video",
            place: "center",
            icon: "<span class='mif-video-camera'></span>",
            content: `
<form>
<div class="form-group">
    <label>File format:</label>
    <select id="videoExportFormat" data-role="select">
        <option value="webm" selected="selected">webm</option>
        <option value="gif">gif</option>
        <option value="png">png</option>
        <option value="jpg">jpg</option>
    </select>
    <small class="text-muted">Webm is a modern video format that is low in file size, while gif takes significantly more space.<br>If you select png or jpg, the output will be a compressed tar of images.</small>

</div>
<div class="form-group">
    <input type="number" value="10" id="videoFramerate" data-role="input" data-prepend="Frame rate" data-append="fps">
    <small class="text-muted">Number of frames per second (used for webm and gif)</small>
</div>
<div class="form-group">
    <input type="number" value="1" id="videoScaleFactor" data-role="input" data-prepend="Scale factor" data-append="times">
    <small class="text-muted">Increase this to get a higher-resolution video</small>
</div>
</form>
<hr>
<div class="form-group">
    <button class="secondary button" onclick="api.exportOrbitingVideo()">Create orbiting video</button>
    <small class="text-muted">Orbit around the patches while recording the video</small>
</div>
<hr>
<button id="videoExportStartButton" class="primary button" onclick="api.exportVideo()">Start</button>
<div id="videoExportProgress" data-role="progress" data-type="load" data-value="35" style="display: none"></div>
`
        });
    }

    /**
     * Export video where the camera orbits a given target (by default the center of mass)ยจ
     * while the trajectory advances.
     * Change the window size to get a different aspect ratio.
     * @param {string} format Video format ("webm", "gif", "png", or "jpg"), the latter two being a set of images in a tar file.
     * @param {number} framerate Number of frames per second
     * @param {number} scaleFactor Multiplier to increase the video resolution
     * @param {number} distance Distance to orbit at
     * @param {number} height Height to orbit at
     * @param {number} nOrbits Number of orbits during the whole trajectory
     * @param {THREE.Vector3} target Target to orbit around
     */
    exportOrbitingVideo(format, framerate, scaleFactor, distance=100, height=50, nOrbits=1, target = this.patchManager.calcPatchesCentre()) {
        const cameraPathFunction = progress => {
            // Make a circle
            const position = new THREE.Vector3(
                target.x + distance * Math.cos(progress * nOrbits*2*Math.PI),
                height,
                target.z + distance * Math.sin(progress * nOrbits*2*Math.PI)
            );
            return {position, target};
        };
        this.exportVideo(format, framerate, scaleFactor, cameraPathFunction);
    }

    /**
     * Create a video of the trees growing
     * Change the window size to get a different aspect ratio.
     * @param {string} format Video format ("webm", "gif", "png", or "jpg"),
     * the latter two being a set of images in a tar file.
     * @param {number} framerate Number of frames per second
     * @param {number} scaleFactor Multiplier to increase the video resolution
     * @param {function(number): {Vector3, Vector3}} cameraPathFunction
     * Optional function to move the camera as the trajectory progresses. See
     * exportOrbitingVideo() for example usage.
     */
    exportVideo(format, framerate, scaleFactor, cameraPathFunction) {
        if (format === undefined) {
            format = document.getElementById("videoExportFormat").value;
        }
        if (framerate === undefined) {
            framerate = document.getElementById("videoFramerate").valueAsNumber;
        }
        if (scaleFactor === undefined) {
            scaleFactor = document.getElementById("videoScaleFactor").valueAsNumber;
        }

        let stop = false;
        const button = document.getElementById("videoExportStartButton");
        button.innerText = "Stop";
        button.onclick = ()=>{
            stop = true;
        };

        // eslint-disable-next-line no-undef
        const capturer = new CCapture({
            format: format,
            framerate: framerate,
            name: this.patchManager.datasetName,
            workersPath: "libs/"
        });
        capturer.start();

        this.scaleCanvas(scaleFactor);

        this.scene.background = new THREE.Color(0xFFFFFF);

        const lastYear = Math.max(...this.patchManager.years);
        const firstYear = this.patchManager.currentYear;
        const progressBar = document.getElementById("videoExportProgress");
        progressBar.style.display = "block";

        const step = () => {
            if (this.patchManager.currentYear >= lastYear || stop) {
                capturer.stop();
                capturer.save();
                this.scene.background = null;
                this.scaleCanvas(1/scaleFactor);
                button.onclick = ()=>{this.exportVideo();};
                button.innerText = "Start";
                progressBar.style.display = "none";
            } else {
                this.nextYear();
                const progress = (this.patchManager.currentYear - firstYear) / (lastYear - firstYear);
                if (cameraPathFunction !== undefined) {
                    const s = cameraPathFunction(progress);
                    this.camera.position.copy(s.position);
                    this.controls.target.copy(s.target);
                    this.controls.update();
                }
                this.render();
                capturer.capture(this.renderer.domElement);
                progressBar.dataset.value = (100 * progress);
                requestAnimationFrame(step);
            }
        };

        // Get first frame
        if (cameraPathFunction !== undefined) {
            const s = cameraPathFunction(0);
            this.camera.position.copy(s.position);
            this.controls.target.copy(s.target);
            this.controls.update();
        }
        this.render();
        capturer.capture(this.renderer.domElement);

        // Step through the rest of the trajectory
        step();
    }
}

export {Api};