PMA.UI Documentation by Pathomation

components/js/slideLoader.js

import { checkBrowserCompatibility } from "../../view/helpers";
import { Viewport } from "../../view/viewport";
import { Events, DragDropMimeType, parseDragData, _sessionList } from "./components";

var PMA = PMA || {};
function clone(obj) {
    if (null === obj || "object" != typeof obj) {
        return obj;
    }

    var copy = obj.constructor();
    for (var attr in obj) {
        // if (obj.hasOwnProperty(attr)) {
        if (Object.prototype.hasOwnProperty.call(obj, attr)) {
            copy[attr] = obj[attr];
        }
    }

    return copy;
}

function checkReloadImage(serverUrl, path, doneCb, dropped) {
    if (!this.lastLoadImageRequest) {
        return;
    }

    if (this.lastLoadImageRequest.serverUrl !== serverUrl || this.lastLoadImageRequest.path !== path || this.lastLoadImageRequest.doneCb !== doneCb) {
        var ref = this.lastLoadImageRequest;
        this.lastLoadImageRequest = null;

        this.load(ref.serverUrl, ref.path, ref.doneCb, ref.dropped);
    }
}

function onDragOver(ev) {
    ev.preventDefault();
    var types = ev.dataTransfer.types;
    if (types) {
        var hasData = false;
        if (types.indexOf) {
            hasData = types.indexOf("application/x-fancytree-node") > -1 || types.indexOf(DragDropMimeType) > -1;
        } else if (types.contains) {
            // in IE and EDGE types is DOMStringList
            hasData = types.contains("application/x-fancytree-node") || types.contains(DragDropMimeType);
        }

        if (hasData) {
            ev.dataTransfer.dropEffect = "link";
            return;
        }
    }

    ev.dataTransfer.dropEffect = "none";
}

function onDrop(ev) {
    ev.preventDefault();
    var nodeData = parseDragData(ev.dataTransfer);
    if (nodeData && !nodeData.isFolder && nodeData.path && nodeData.serverUrl) {
        var cancels = this.fireEvent(Events.BeforeDrop, { serverUrl: nodeData.serverUrl, path: nodeData.path, node: nodeData });
        if (
            cancels.filter(function (c) {
                return c == false;
            }).length == 0
        ) {
            this.load(nodeData.serverUrl, nodeData.path, null, true);
        }
    }
}

function initializeDropZone() {
    if (this.element) {
        this.element.addEventListener("drop", onDrop.bind(this), false);
        this.element.addEventListener("dragover", onDragOver.bind(this), false);
    }
}

export /**
 * Helper class that wraps around the {@link Viewport|PMA.UI.View.Viewport} class. It's purpose is mainly to automatically handle slide reloading and authentication, via the provided {@link Context|PMA.UI.Components.Context} instance.
 * @memberof PMA.UI.Components
 * @alias SlideLoader
 * @param {Context} context
 * @param {Object} slideLoaderOptions - Initialization options passed to each {@link Viewport|PMA.UI.View.Viewport} that is created during a {@link SlideLoader#load|load} call. This is the same struct as the one accepted by the {@link Viewport|PMA.UI.View.Viewport} constructor, omitting server URLs, credentials and specific slide paths. The omitted information is either available via the {@link Context|PMA.UI.Components.Context} instance, or supplied during the {@link SlideLoader#load|load} call.
 * @param {string|HTMLElement} slideLoaderOptions.element - The element that hosts the viewer. It can be either a valid CSS selector or an HTMLElement instance
 * @param {string} slideLoaderOptions.image - The path or UID of the image to load
 * @param {Number} [slideLoaderOptions.keyboardPanFactor=0.5] - A factor to calculate pan delta when pressing a keyboard arrow. The actual pan in pixels is calculated as keyboardPanFactor * viewportWidth.
 * @param {Number} [slideLoaderOptions.keyboardZoomDelta=1] - The zoom level delta on each key press.
 * @param {PMA.UI.View.Themes} [slideLoaderOptions.theme="default"] - The theme to use
 * @param {boolean|Object} [slideLoaderOptions.overview=true] - Whether or not to display an overview map
 * @param {boolean} [slideLoaderOptions.overview.collapsed] - Whether or not to start the overview in collapsed state
 * @param {boolean|Object} [slideLoaderOptions.dimensions=true] - Whether or not to display the dimensions selector for images that have more than one channel, z-stack or timeframe
 * @param {boolean} [slideLoaderOptions.dimensions.collapsed] - Whether or not to start the dimensions selector in collapsed state
 * @param {boolean|Object} [slideLoaderOptions.barcode=false] - Whether or not to display the image's barcode if it exists
 * @param {boolean} [slideLoaderOptions.barcode.collapsed=undefined] - Whether or not to start the barcode in collapsed state
 * @param {Number} [slideLoaderOptions.barcode.rotation=undefined] - Rotation in steps of 90 degrees
 * @param {boolean|Object} [slideLoaderOptions.loadingBar=true] - Whether or not to display a loading bar while the image is loading
 * @param {Viewport~position} [slideLoaderOptions.position] - The initial position of the viewport within the image
 * @param {boolean} [slideLoaderOptions.snapshot=false] - Whether or not to display a button that generates a snapshot image
 * @param {Viewport~annotationOptions} [slideLoaderOptions.annotations] - Annotation options
 * @param {Number} [slideLoaderOptions.digitalZoomLevels=0] - The number of digital zoom levels to add
 * @param {Number} [slideLoaderOptions.animationDuration=0] - The duration of transition animations in ms (0 for no animations)
 * @param {boolean} [slideLoaderOptions.mouseWheelZoomAnimations=false] - Whether or not to apply animations to mouse wheel zoom
 * @param {boolean} [slideLoaderOptions.panAnimations=false] - Whether or not to apply animations to pan operations too
 * @param {boolean} [slideLoaderOptions.scaleLine=true] - Whether or not to display a scale line when resolution information is available
 * @param {boolean} [slideLoaderOptions.colorAdjustments=false] - Whether or not to add a control that allows color adjustments
 * @param {string|Viewport~filenameCallback} [slideLoaderOptions.filename] - A string to display as the file name or a callback function. If no value is supplied, no file name is displayed.
 * @param {boolean|Viewport~attributionOptions}[slideLoaderOptions.attributions=undefined] - Whether or not to display Pathomation attribution in the viewer
 * @param {Array<Viewport~customButton>} [slideLoaderOptions.customButtons] - An array of one or more custom buttons to add to the viewer
 * @param {Object|boolean} [options.magnifier=false] - Whether or not to show the magnifier control
 * @param {Object|boolean} [options.magnifier.collapsed=undefined] - Whether or not to show the magnifier control in collapsed state
 * @param {Object} [options.grid] - Options for measurement grid
 * @param {Array<number>} [options.grid.size] - Grid cell width and height in micrometers
 * @param {string} [options.grid.color="#c0c0c0"] - Valid CSS color for the grid
 * @fires PMA.UI.Components.Events.SlideInfoError
 * @fires PMA.UI.Components.Events.BeforeSlideLoad
 * @fires PMA.UI.Components.Events.SlideLoaded
 * @fires PMA.UI.Components.Events.BeforeDrop
 * @tutorial 03-gallery
 * @tutorial 04-tree
 * @tutorial 05-annotations
 */
class SlideLoader {
    constructor(context, slideLoaderOptions) {
        if (!checkBrowserCompatibility()) {
            return;
        }

        this.loadingImage = false;
        this.lastLoadImageRequest = null;
        this.context = context;
        this.slideLoaderOptions = slideLoaderOptions || {};

        this.listeners = {};
        this.listeners[Events.SlideInfoError] = [];
        this.listeners[Events.BeforeSlideLoad] = [];
        this.listeners[Events.SlideLoaded] = [];
        this.listeners[Events.BeforeDrop] = [];

        /**
         * The currently loaded {@link Viewport|PMA.UI.View.Viewport} instance, or null
         * @public
         */
        this.mainViewport = null;

        // try to get the element
        if (this.slideLoaderOptions.element) {
            if (this.slideLoaderOptions.element instanceof HTMLElement) {
                this.element = this.slideLoaderOptions.element;
            } else if (typeof this.slideLoaderOptions.element == "string") {
                var el = document.querySelector(this.slideLoaderOptions.element);
                if (!el) {
                    throw "Invalid selector for element";
                } else {
                    this.element = el;
                }
            }
        }

        initializeDropZone.call(this);
    }
    /**
     * Sets or overrides the value of an option. Useful when it is required to modify a viewer option before loading as slide.
     * @param  {string} option
     * @param  {any} value
     */
    setOption(option, value) {
        this.slideLoaderOptions[option] = value;
    }
    /**
     * Gets the value of a viewer option.
     * @param  {string} option
     * @param  {any} value
     * @return {any} The value of the option or undefined
     */
    getOption(option) {
        return this.slideLoaderOptions[option];
    }

    /**
     * Creates a {@link Viewport|PMA.UI.View.Viewport} instance that loads the requested slide macro image
     * @param  {string} serverUrl - PMA.core server URL
     * @param  {string} path - Path or UID of the slide, of which the macro image to load
     * @param  {string} [wsiViewport] - Optionally, the {@link Viewport|PMA.UI.View.Viewport} instance of the corresponding WSI image, to which the macro image should be aligned
     * @param  {function} [doneCb] - Called when the slide has finished loading
     * @fires PMA.UI.Components.Events.BeforeSlideLoad
     * @fires PMA.UI.Components.Events.SlideLoaded
     * @fires PMA.UI.Components.Events.SlideInfoError
     */
    loadMacro(serverUrl, path, wsiViewport, doneCb) {
        if (!serverUrl.endsWith("/")) {
            serverUrl += "/";
        }

        const encodedPath = encodeURIComponent(path);
        const self = this;

        function createViewportWithMacro(macroImage, autoRegistrationData, wsiImageInfo) {
            autoRegistrationData = autoRegistrationData || { Success: false };
            let macroMicronsPerPixel = 1;
            let opts = clone(self.slideLoaderOptions);
            let registrationResult = null;
            opts.referenceImage = {
                src: macroImage.src,
                backgroundColor: "#ffffff",
            };

            opts.dimensions = false;

            if (autoRegistrationData.Success === true) {
                registrationResult = JSON.parse(autoRegistrationData.Result);

                const wsiTilesPerBoxSide = Math.pow(2, wsiImageInfo.MaxZoomLevel);
                const wsiMapHeight = wsiTilesPerBoxSide * wsiImageInfo.TileSize;

                registrationResult.wsi.map((pt) => {
                    pt[1] = wsiMapHeight - pt[1];
                });

                const macroHeight = macroImage.height;
                registrationResult.macro.map((pt) => {
                    pt[1] = macroHeight - pt[1];
                });

                const wsiPointPairXLength = Math.abs(registrationResult.wsi[1][0] - registrationResult.wsi[0][0]);
                const macroPointPairXLength = Math.abs(registrationResult.macro[1][0] - registrationResult.macro[0][0]);

                macroMicronsPerPixel = (wsiImageInfo.MicrometresPerPixelX * wsiPointPairXLength) / macroPointPairXLength;
            }

            self.mainViewport = new Viewport(
                opts,
                function () {
                    self.mainViewport.imageInfo.MicrometresPerPixelX = macroMicronsPerPixel;
                    self.mainViewport.imageInfo.MicrometresPerPixelY = macroMicronsPerPixel;

                    if (wsiViewport && registrationResult) {
                        self.mainViewport.alignViewports(wsiViewport, registrationResult.macro, registrationResult.wsi);
                    }

                    setTimeout(() => {
                        self.loadingImage = false;
                        self.fireEvent(Events.SlideLoaded, {});

                        if (typeof doneCb === "function") {
                            doneCb();
                        }
                    }, 10);
                },
                function () {
                    self.loadingImage = false;
                    console.error("Error loading slide");
                }
            );
        }

        function autoRegisterMacro(sessionId, wsiImageInfo, macroImage) {
            const autoAlignUrl = `${serverUrl}scripts/Run?name=macro_auto_register&sessionID=${sessionId}&server_url=${serverUrl}&path=${encodedPath}`;
            return fetch(autoAlignUrl)
                .then((response) => response.json())
                .then((data) => {
                    createViewportWithMacro(macroImage, data, wsiImageInfo);
                })
                .catch((error) => {
                    self.loadingImage = false;
                    console.error(`Auto registration failed: ${error}`);
                });
        }

        self.context.getImageInfo(
            serverUrl,
            path,
            function (sessionId, wsiImageInfo) {
                const macroSrc = `${serverUrl}macro?sessionID=${sessionId}&pathOrUid=${encodedPath}`;

                let macroImage = new Image();
                macroImage.onload = function () {
                    if (wsiViewport) {
                        autoRegisterMacro(sessionId, wsiImageInfo, macroImage);
                    } else {
                        createViewportWithMacro(macroImage, null, wsiImageInfo);
                    }
                };

                macroImage.src = macroSrc;
            },
            function () {
                self.loadingImage = false;
                self.fireEvent(Events.SlideInfoError, {});
                if (typeof doneCb === "function") {
                    doneCb();
                }
            }
        );
    }

    /**
     * Creates a {@link Viewport|PMA.UI.View.Viewport} instance that loads the requested static image.
     * @param  {string} src - Source of the static image to load
     * @param  {function} [doneCb] - Called when the slide has finished loading
     * @fires PMA.UI.Components.Events.BeforeSlideLoad
     * @fires PMA.UI.Components.Events.SlideLoaded
     * @fires PMA.UI.Components.Events.SlideInfoError
     */
    loadStatic(src, doneCb) {
        if (this.loadingImage === true) {
            return;
        }

        this.loadingImage = true;

        if (this.mainViewport && this.mainViewport.map) {
            while (this.mainViewport.map.getInteractions().getLength() > 0) {
                this.mainViewport.map.removeInteraction(this.mainViewport.map.getInteractions().item(0));
            }

            while (this.mainViewport.map.getLayers().getLength() > 0) {
                this.mainViewport.map.removeLayer(this.mainViewport.map.getLayers().item(0));
            }

            while (this.mainViewport.map.getControls().getLength() > 0) {
                this.mainViewport.map.removeControl(this.mainViewport.map.getControls().item(0));
            }
        }

        var beforeLoadEa = { cancel: false };
        this.fireEvent(Events.BeforeSlideLoad, beforeLoadEa);

        if (beforeLoadEa.cancel) {
            this.loadingImage = false;
            return;
        }

        if (!src) {
            if (this.mainViewport) {
                this.mainViewport.element.innerHTML = "";
                this.mainViewport = null;
            }

            this.loadingImage = false;
            this.fireEvent(Events.SlideLoaded, { src: src });
            if (typeof doneCb === "function") {
                doneCb();
            }

            return;
        }

        var _this = this;
        var opts = clone(_this.slideLoaderOptions);

        _this.mainViewport = new Viewport(
            opts,
            function () {
                _this.loadingImage = false;
                _this.fireEvent(Events.SlideLoaded, {});

                if (typeof doneCb === "function") {
                    doneCb();
                }
            },
            function () {
                _this.loadingImage = false;
                console.error("Error loading slide");
            }
        );
    }

    /**
     * Creates a {@link Viewport|PMA.UI.View.Viewport} instance that loads the requested slide
     * @param  {string} serverUrl - PMA.core server URL
     * @param  {string} path - Path or UID of the slide load
     * @param  {function} [doneCb] - Called when the slide has finished loading
     * @param {boolean} [dropped] - Whether this slide was loaded by a drag and drop operation
     * @fires PMA.UI.Components.Events.BeforeSlideLoad
     * @fires PMA.UI.Components.Events.SlideLoaded
     * @fires PMA.UI.Components.Events.SlideInfoError
     */
    load(serverUrl, path, doneCb, dropped) {
        if (this.loadingImage === true) {
            this.lastLoadImageRequest = {
                serverUrl: serverUrl,
                path: path,
                doneCb: doneCb,
                dropped: dropped === true ? true : false,
            };

            return;
        }

        if (dropped !== true) {
            dropped = false;
        }

        this.loadingImage = true;

        if (this.mainViewport && this.mainViewport.map) {
            while (this.mainViewport.map.getInteractions().getLength() > 0) {
                this.mainViewport.map.removeInteraction(this.mainViewport.map.getInteractions().item(0));
            }

            while (this.mainViewport.map.getLayers().getLength() > 0) {
                this.mainViewport.map.removeLayer(this.mainViewport.map.getLayers().item(0));
            }

            while (this.mainViewport.map.getControls().getLength() > 0) {
                this.mainViewport.map.removeControl(this.mainViewport.map.getControls().item(0));
            }
        }

        var beforeLoadEa = { serverUrl: serverUrl, path: path, cancel: false };
        this.fireEvent(Events.BeforeSlideLoad, beforeLoadEa);

        if (beforeLoadEa.cancel) {
            this.loadingImage = false;
            return;
        }

        var _this = this;
        if (!serverUrl || !path) {
            if (this.mainViewport) {
                this.mainViewport.element.innerHTML = "";
                this.mainViewport = null;
            }

            this.loadingImage = false;
            this.fireEvent(Events.SlideLoaded, { serverUrl: serverUrl, path: path, dropped: dropped });
            if (typeof doneCb === "function") {
                doneCb();
            }

            checkReloadImage.call(_this, serverUrl, path, doneCb, dropped);
            return;
        }

        this.context.getSession(
            serverUrl,
            function (sessionId) {
                var opts = clone(_this.slideLoaderOptions);

                opts.serverUrls = [serverUrl];
                opts.image = path;
                opts.sessionID = sessionId;
                opts.caller = _this.context.getCaller();

                _this.mainViewport = new Viewport(
                    opts,
                    function () {
                        _this.loadingImage = false;
                        _this.fireEvent(Events.SlideLoaded, { serverUrl: serverUrl, path: path, dropped: dropped });

                        if (typeof doneCb === "function") {
                            doneCb();
                        }

                        checkReloadImage.call(_this, serverUrl, path, doneCb, dropped);
                    },
                    function () {
                        _this.loadingImage = false;
                        console.error("Error loading slide");
                    }
                );

                var count = 0;
                _this.mainViewport.listen("tileserror", function () {
                    if (count === 0) {
                        count++;
                        _sessionList.set(serverUrl, null);

                        _this.context.getSession(
                            serverUrl,
                            function (newSessionId) {
                                count = -1;
                                _this.mainViewport.sessionID = newSessionId;
                                _this.mainViewport.redraw();
                            },
                            function () {
                                _this.fireEvent(Events.SlideInfoError, {});
                            }
                        );
                    } else if (count === -1) {
                        count = 0;
                        _this.fireEvent(Events.SlideInfoError, {});
                    }
                });

                _this.mainViewport.listen("SlideLoadError", function () {
                    _this.loadingImage = false;
                    _this.fireEvent(Events.SlideInfoError, {});
                    checkReloadImage.call(_this, serverUrl, path, doneCb, dropped);
                });
            },
            function () {
                _this.loadingImage = false;
                _this.fireEvent(Events.SlideInfoError, {});
                checkReloadImage.call(_this, serverUrl, path, doneCb, dropped);
            }
        );
    }
    /**
     * Gets the image info of the currently loaded image
     * @return {object|null}
     */
    getLoadedImageInfo() {
        if (this.mainViewport && this.mainViewport.map && this.mainViewport.imageInfo) {
            return this.mainViewport.imageInfo;
        }

        return null;
    }
    /**
     * Reloads annotations from the server
     * @param {function} [readyCallback] - Called when the annotations have finished loading
     */
    reloadAnnotations(readyCallback) {
        if (this.mainViewport && this.mainViewport.map && this.mainViewport.imageInfo) {
            this.mainViewport.reloadAnnotations(readyCallback);
            return;
        }

        if (typeof readyCallback === "function") {
            readyCallback.call(this);
        }
    }
    /**
     * Attaches an event listener
     * @param {PMA.UI.Components.Events} eventName - The name of the event to listen to
     * @param {function} callback - The function to call when the event occurs
     */
    listen(eventName, callback) {
        // if (!this.listeners.hasOwnProperty(eventName)) {
        if (!Object.prototype.hasOwnProperty.call(this.listeners, eventName)) {
            console.error(eventName + " is not a valid event");
        }

        this.listeners[eventName].push(callback);
    }
    // fires an event
    fireEvent(eventName, eventArgs) {
        // if (!this.listeners.hasOwnProperty(eventName)) {
        if (!Object.prototype.hasOwnProperty.call(this.listeners, eventName)) {
            console.error(eventName + " does not exist");
            return;
        }

        var returns = [];
        for (var i = 0, max = this.listeners[eventName].length; i < max; i++) {
            returns.push(this.listeners[eventName][i].call(this, eventArgs));
        }

        return returns;
    }

    /**
     * Draw annotations for each slide in collage mode
     * @param {string} serverUrl - The server used to load the images
     * @param {function} slidePathTransform - A function to transform each filename to a slide name
     */

    drawCollageAnnotations(serverUrl, slidePathTransform) {
        let collagePath = this.mainViewport.image;
        if (!collagePath.startsWith("{")) {
            return;
        }

        let collageObj = JSON.parse(collagePath);
        let sources = collageObj["Sources"];
        let collectionRows = collageObj["CollectionRows"];
        let tileMargin = collageObj["CollectionTileMargin"];
        let layout = collageObj["CollectionLayout"];
        if (!sources || sources.length == 0) {
            return;
        }

        this.context.getImagesInfo({
            serverUrl: serverUrl,
            images: sources,
            success: (sessionId, images) => {
                let annotations = new PMA.UI.Components.Annotations({
                    // eslint-disable-line no-undef
                    context: this.context,
                    viewport: this.mainViewport,
                    serverUrl: serverUrl,
                    path: "",
                    enabled: true,
                });

                let minresX = Math.min.apply(
                    Math,
                    images.map((im) => im.MicrometresPerPixelX)
                );
                let minresY = Math.min.apply(
                    Math,
                    images.map((im) => im.MicrometresPerPixelY)
                );

                let c = Math.round(images.length / collectionRows);
                c = Math.max(1, c);
                let sx = 0;
                let sy = 0;
                let maxHeight = 0;
                for (let i = 0; i < images.length; i++) {
                    let mX = images[i].MicrometresPerPixelX / minresX;
                    let mY = images[i].MicrometresPerPixelY / minresY;

                    let w = Math.round(images[i].Width * mX);
                    let h = Math.round(images[i].Height * mY);

                    annotations.addAnnotation({
                        LayerID: 0,
                        Geometry: `POLYGON ((${sx} ${sy}, ${sx} ${sy + h}, ${sx + w} ${sy + h},${sx + w} ${sy}, ${sx} ${sy}))`,
                        Notes: slidePathTransform(images[i].Filename),
                    });

                    if (layout == 0) {
                        sx += w + tileMargin;
                        maxHeight = Math.max(maxHeight, h);
                    } else {
                        sy += h + tileMargin;
                        maxHeight = Math.max(maxHeight, w);
                    }

                    if ((i + 1) % c == 0) {
                        if (layout == 0) {
                            sx = 0;
                            sy += maxHeight + tileMargin;
                            maxHeight = 0;
                        } else {
                            sy = 0;
                            sx += maxHeight + tileMargin;
                            maxHeight = 0;
                        }
                    }
                }
            },
        });
    }
}