(function () {
    'use strict';

    /**
     * Class that represents a PixiJS Theme.
     *
     * @example
     * // Create the theme
     * const yellow = new Theme({
     *     fill: 0xfecd2d,
     *     fillActive: 0xfe9727,
     *     strokeActive: 0xfecd2d,
     *     strokeActiveWidth: 4,
     *     textStyle: {
     *         fill: 0x5ec7f8
     *     },
     *     textStyleActive: {
     *         fill: 0x5954d3
     *     },
     *     textStyleLarge: {
     *         fontSize: 36
     *     }
     * })
     *
     * // Create the app and apply the new theme to it
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 450,
     *     height: 150,
     *     theme: yellow
     * }).setup().run()
     *
     * @class
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
     */
    class Theme {
        /**
         * Creates an instance of a Theme.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the theme.
         * @param {number} [opts.margin=10] - The outer spacing (distance to other objects) from the border.
         * @param {number} [opts.padding=10] - The inner spacing (distance from icon and/or label) to the border.
         * @param {number} [opts.radius=4] - The radius used when drawing a rounded rectangle.
         * @param {number} [opts.fast=0.25] - The duration of time when it has to be fast.
         * @param {number} [opts.normal=0.5] - The duration of time when it has to be normal.
         * @param {number} [opts.slow=1] - The duration of time when it has to be slow.
         * @param {number} [opts.primaryColor=0x5ec7f8] - The primary color of the theme.
         * @param {number} [opts.color1=0x282828] - The first color of the theme. For example used for the background.
         * @param {number} [opts.color2=0xf6f6f6] - The second color of the theme. For example used for the border.
         * @param {number} [opts.fill=color1] - The color of the background as a hex value.
         * @param {number} [opts.fillAlpha=1] - The alpha value of the background.
         * @param {number} [opts.fillActive=color1] - The color of the background when activated.
         * @param {number} [opts.fillActiveAlpha=1] - The alpha value of the background when activated.
         * @param {number} [opts.stroke=color2] - The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=0.6] - The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=1] - The alpha value of the border.
         * @param {number} [opts.strokeActive=color2] - The color of the border when activated.
         * @param {number} [opts.strokeActiveWidth=0.6] - The width of the border in pixel when activated.
         * @param {number} [opts.strokeActiveAlpha=1] - The alpha value of the border when activated.
         * @param {number} [opts.iconColor=color2] - The color of the icon (set by the tint property) as a hex value.
         * @param {number} [opts.iconColorActive=colorPrimary] - The color of the icon when activated.
         * @param {number} [opts.background=color1] - The color of a background for a component (e.g. at the Modal class).
         * @param {object} [opts.textStyle={}] - A textstyle object for the styling of text. See PIXI.TextStyle
         *     for possible options. Default object:
         * @param {string} [opts.textStyle.fontFamily="Avenir Next", "Open Sans", "Segoe UI", ...] - The font family.
         * @param {string} [opts.textStyle.fontWeight=400] - The font weight.
         * @param {number} [opts.textStyle.fontSize=16] - The font size.
         * @param {number} [opts.textStyle.fill=color2] - The fill color.
         * @param {number} [opts.textStyle.stroke=color1] - The stroke color.
         * @param {number} [opts.textStyle.strokeThickness=0] - The thickness of the stroke.
         * @param {number} [opts.textStyle.miterLimit=1] - The meter limit.
         * @param {string} [opts.textStyle.lineJoin=round] - The line join.
         * @param {object} [opts.textStyleActive=textStyle + {fill: primaryColor}] - A textstyle object which is used
         *     for actived text.
         * @param {object} [opts.textStyleSmall=textStyle + {fontSize: -= 3}] - A textstyle object which is used for
         *     small text.
         * @param {object} [opts.textStyleSmallActive=textStyleSmall + {fill: primaryColor}] - A textstyle object which
         *     is used for small actived text.
         * @param {object} [opts.textStyleLarge=textStyle + {fontSize: += 3}] - A textstyle object which is used for
         *     large text.
         * @param {object} [opts.textStyleLargeActive=textStyleLarge + {fill: primaryColor}] - A textstyle object which
         *     is used for large actived text.
         */
        constructor(opts = {}) {
            const colorPrimary =
                opts.primaryColor != null ? opts.primaryColor : 0x5ec7f8; // blue
            const color1 = opts.color1 != null ? opts.color1 : 0x282828; // black
            const color2 = opts.color2 != null ? opts.color2 : 0xf6f6f6; // white

            this.opts = Object.assign(
                {},
                {
                    margin: 12,
                    padding: 12,
                    radius: 4,
                    fast: 0.25,
                    normal: 0.5,
                    slow: 1,
                    primaryColor: colorPrimary,
                    color1: color1,
                    color2: color2,
                    fill: color1,
                    fillAlpha: 1,
                    fillActive: color1,
                    fillActiveAlpha: 1,
                    stroke: color2,
                    strokeWidth: 0.6,
                    strokeAlpha: 1,
                    strokeActive: color2,
                    strokeActiveWidth: 0.6,
                    strokeActiveAlpha: 1,
                    iconColor: color2,
                    iconColorActive: colorPrimary,
                    background: color1
                },
                opts
            );

            // Set textStyle and variants
            this.opts.textStyle = Object.assign(
                {},
                {
                    fontFamily:
                        '"Avenir Next", "Open Sans", "Segoe UI", "Roboto", "Helvetica Neue", -apple-system, system-ui, BlinkMacSystemFont, Arial, sans-serif !default',
                    fontWeight: '500',
                    fontSize: 18,
                    fill: color2,
                    stroke: color1,
                    strokeThickness: 0,
                    miterLimit: 1,
                    lineJoin: 'round'
                },
                this.opts.textStyle
            );
            this.opts.textStyleSmall = Object.assign(
                {},
                this.opts.textStyle,
                { fontSize: this.opts.textStyle.fontSize - 3 },
                this.opts.textStyleSmall
            );
            this.opts.textStyleLarge = Object.assign(
                {},
                this.opts.textStyle,
                { fontSize: this.opts.textStyle.fontSize + 3 },
                this.opts.textStyleLarge
            );
            this.opts.textStyleActive = Object.assign(
                {},
                this.opts.textStyle,
                { fill: this.opts.primaryColor },
                this.opts.textStyleActive
            );
            this.opts.textStyleSmallActive = Object.assign(
                {},
                this.opts.textStyleSmall,
                { fill: this.opts.primaryColor },
                this.opts.textStyleSmallActive
            );
            this.opts.textStyleLargeActive = Object.assign(
                {},
                this.opts.textStyleLarge,
                { fill: this.opts.primaryColor },
                this.opts.textStyleLargeActive
            );

            Object.assign(this, this.opts);
        }

        /**
         * Factory function
         *
         * @static
         * @param {string} theme=dark - The name of the theme to load.
         * @return {Theme} Returns a newly created Theme object.
         */
        static fromString(theme) {
            if (theme && typeof theme === 'object') {
                return theme
            }

            switch (theme) {
                case 'light':
                    return new ThemeLight()
                case 'red':
                    return new ThemeRed()
                default:
                    return new ThemeDark()
            }
        }
    }

    /**
     * Class that represents a PixiJS ThemeDark.
     *
     * @example
     * // Create the app with a new dark theme
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 450,
     *     height: 150,
     *     theme: new ThemeDark()
     * }).setup().run()
     *
     * @class
     * @extends Theme
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
     */
    class ThemeDark extends Theme {}

    /**
     * Class that represents a PixiJS ThemeLight.
     * The color1 is set to 0xf6f6f6, color2 to 0x282828.
     *
     * @example
     * // Create the app with a new light theme
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 450,
     *     height: 150,
     *     theme: new ThemeLight()
     * }).setup().run()
     *
     * @class
     * @extends Theme
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
     */
    class ThemeLight extends Theme {
        /**
         * Creates an instance of a ThemeLight.
         *
         * @constructor
         */
        constructor() {
            super({ color1: 0xf6f6f6, color2: 0x282828 });
        }
    }

    /**
     * Class that represents a PixiJS ThemeRed.
     * The primaryColor is set to 0xd92f31.
     *
     * @example
     * // Create the app with a new red theme
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 450,
     *     height: 150,
     *     theme: new ThemeRed()
     * }).setup().run()
     *
     * @class
     * @extends Theme
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/theme.html|DocTest}
     */
    class ThemeRed extends Theme {
        /**
         * Creates an instance of a ThemeRed.
         *
         * @constructor
         */
        constructor() {
            super({ primaryColor: 0xd92f31 });
        }
    }

    /**
     * Class that represents a PixiJS Progress.
     *
     * @example
     * // Create the progress
     * const progress = new Progress({
     *     app: app
     * })
     *
     * // Add the progress to a DisplayObject
     * app.scene.addChild(progress)
     *
     * @class
     * @extends PIXI.Container
     * @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/progress.html|DocTest}
     */
    class Progress extends PIXI.Container {
        /**
         * Creates an instance of a Progress.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the progress.
         * @param {number} [opts.id=auto generated] - The id of the progress.
         * @param {PIXIApp} [opts.app=window.app] - The app where the progress belongs to.
         * @param {number} [opts.width] - The width of the progress bar. When not set, the width is the size of the app
         *     minus 2 * opts.margin.
         * @param {number} [opts.height=2] - The height of the progress bar.
         * @param {string|Theme} [opts.theme=dark] - The theme to use for this progress. Possible values are dark, light, red
         *     or a Theme object.
         * @param {number} [opts.margin=100] - The outer spacing to the edges of the app.
         * @param {number} [opts.padding=0] - The inner spacing (distance from icon and/or label) to the border.
         * @param {number} [opts.fill=Theme.fill] - The color of the progress background as a hex value.
         * @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
         * @param {number} [opts.fillActive=Theme.primaryColor] - The color of the progress background when activated.
         * @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
         * @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=0] - The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
         * @param {number} [opts.strokeActiveWidth=0] - The width of the border in pixel when activated.
         * @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
         * @param {boolean} [opts.background=false] - The alpha value of the border when activated.
         * @param {number} [opts.backgroundFill=Theme.background] - A textstyle object for the styling of the label. See PIXI.TextStyle
         *     for possible options.
         * @param {number} [opts.backgroundFillAlpha=1] - A textstyle object for the styling of the label when the
         *     progress is activated. See PIXI.TextStyle for possible options.
         * @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the progress (which is a rounded rectangle).
         * @param {boolean} [opts.destroyOnComplete=true] - Should the progress bar destroy itself after reaching 100 %?
         * @param {boolean} [opts.visible=true] - Is the progress initially visible (property visible)?
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    app: window.app,
                    width: null,
                    height: 2,
                    margin: 100,
                    padding: 0,
                    fill: theme.fill,
                    fillAlpha: theme.fillAlpha,
                    fillActive: theme.primaryColor,
                    fillActiveAlpha: theme.fillActiveAlpha,
                    stroke: theme.stroke,
                    strokeWidth: 0,
                    strokeAlpha: theme.strokeAlpha,
                    strokeActive: theme.strokeActive,
                    strokeActiveWidth: 0,
                    strokeActiveAlpha: theme.strokeActiveAlpha,
                    background: false,
                    backgroundFill: theme.background,
                    backgroundFillAlpha: 1,
                    radius: theme.radius,
                    destroyOnComplete: true,
                    visible: true
                },
                opts
            );

            this.id = this.opts.id;

            this.background = null;
            this.bar = null;
            this.barActive = null;

            this.alpha = 0;

            this.visible = this.opts.visible;

            this._progress = 0;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Progress} A reference to the progress for chaining.
         */
        setup() {
            // interaction
            //-----------------
            this.on('added', e => {
                this.show();
            });

            // background
            //-----------------
            if (this.opts.background) {
                const background = new PIXI.Graphics();
                this.background = background;
                this.addChild(background);
            }

            // bar
            //-----------------
            const bar = new PIXI.Graphics();
            this.bar = bar;
            this.addChild(bar);

            const barActive = new PIXI.Graphics();
            this.barActive = barActive;
            this.bar.addChild(barActive);

            return this
        }

        /**
         * Should be called to refresh the layout of the progress. Can be used after resizing.
         *
         * @return {Progress} A reference to the progress for chaining.
         */
        layout() {
            const width = this.opts.app.size.width;
            const height = this.opts.app.size.height;

            // background
            //-----------------
            if (this.opts.background) {
                this.background.clear();
                this.background.beginFill(
                    this.opts.backgroundFill,
                    this.opts.backgroundFillAlpha
                );
                this.background.drawRect(0, 0, width, height);
                this.background.endFill();
            }

            this.draw();

            return this
        }

        /**
         * Draws the canvas.
         *
         * @private
         * @return {Progress} A reference to the progress for chaining.
         */
        draw() {
            this.bar.clear();
            this.barActive.clear();

            this.drawBar();
            this.drawBarActive();

            return this
        }

        /**
         * Draws the bar.
         *
         * @private
         * @return {Progress} A reference to the progress for chaining.
         */
        drawBar() {
            const width = this.opts.app.size.width;
            const height = this.opts.app.size.height;

            this.radius = this.opts.radius;
            if (this.radius * 2 > this.opts.height) {
                this.radius = this.opts.height / 2;
            }

            const wantedWidth = this.opts.width || width - 2 * this.opts.margin;
            const wantedHeight = this.opts.height;

            this.bar.lineStyle(
                this.opts.strokeWidth,
                this.opts.stroke,
                this.opts.strokeAlpha
            );
            this.bar.beginFill(this.opts.fill, this.opts.fillAlpha);
            if (this.radius > 1) {
                this.bar.drawRoundedRect(
                    0,
                    0,
                    wantedWidth,
                    wantedHeight,
                    this.radius
                );
            } else {
                this.bar.drawRect(0, 0, wantedWidth, wantedHeight);
            }
            this.bar.endFill();

            this.bar.x = width / 2 - this.bar.width / 2;
            this.bar.y = height / 2 - this.bar.height / 2;

            return this
        }

        /**
         * Draws the active bar.
         *
         * @private
         * @return {Progress} A reference to the progress for chaining.
         */
        drawBarActive() {
            const wantedWidth = this.bar.width - 2 * this.opts.padding;
            const wantedHeight = this.bar.height - 2 * this.opts.padding;

            const barActiveWidth = (wantedWidth * this._progress) / 100;

            this.barActive.lineStyle(
                this.opts.strokeActiveWidth,
                this.opts.strokeActive,
                this.opts.strokeActiveAlpha
            );
            this.barActive.beginFill(
                this.opts.fillActive,
                this.opts.fillActiveAlpha
            );
            if (barActiveWidth > 0) {
                if (this.radius > 1) {
                    this.barActive.drawRoundedRect(
                        0,
                        0,
                        barActiveWidth,
                        wantedHeight,
                        this.radius
                    );
                } else {
                    this.barActive.drawRect(0, 0, barActiveWidth, wantedHeight);
                }
            }
            this.barActive.endFill();

            this.barActive.x = this.opts.padding;
            this.barActive.y = this.opts.padding;

            return this
        }

        /**
         * Shows the progress (sets his alpha values to 1).
         *
         * @return {Progress} A reference to the progress for chaining.
         */
        show() {
            TweenLite.to(this, this.theme.fast, { alpha: 1 });

            return this
        }

        /**
         * Hides the progress (sets his alpha values to 1).
         *
         * @return {Progress} A reference to the progress for chaining.
         */
        hide() {
            TweenLite.to(this, this.theme.fast, {
                alpha: 0,
                onComplete: () => (this.visible = false)
            });

            return this
        }

        /**
         * Gets or sets the progress. Has to be a number between 0 and 100.
         *
         * @member {number}
         */
        get progress() {
            return this._progress
        }
        set progress(value) {
            value = Math.round(value);

            if (value < 0) {
                value = 0;
            }

            if (value > 100) {
                value = 100;
            }

            TweenLite.to(this, this.theme.normal, {
                _progress: value,
                onUpdate: () => this.draw(),
                onComplete: () => {
                    if (value === 100 && this.opts.destroyOnComplete) {
                        TweenLite.to(this, this.theme.fast, {
                            alpha: 0,
                            onComplete: () => this.destroy({ children: true })
                        });
                    }
                }
            });
        }
    }

    /**
     * Class that represents a PixiJS AbstractPopup.
     * The class is used for various other Popup-like classes
     * like Popup, Message, Tooltip...
     *
     * @class
     * @abstract
     * @extends PIXI.Graphics
     * @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
     */
    class AbstractPopup extends PIXI.Graphics {
        /**
         * Creates an instance of an AbstractPopup (only for internal use).
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the popup.
         * @param {number} [opts.id=auto generated] - The id of the popup.
         * @param {number} [opts.x=0] - The x position of the popup. Can be also set after creation with popup.x = 0.
         * @param {number} [opts.y=0] - The y position of the popup. Can be also set after creation with popup.y = 0.
         * @param {string|Theme} [opts.theme=dark] - The theme to use for this popup. Possible values are dark, light, red
         *     or a Theme object.
         * @param {string|number|PIXI.Text} [opts.header] - The heading inside the popup as a string, a number (will be
         *     converted to a text) or as a PIXI.Text object.
         * @param {string|number|PIXI.DisplayObject} [opts.content] - A text, a number (will be converted to a text) or
         *     an PIXI.DisplayObject as the content of the popup.
         * @param {number} [opts.minWidth=320] - The minimum width of the popup.
         * @param {number} [opts.minHeight=130] - The minimum height of the popup.
         * @param {number} [opts.padding=Theme.padding] - The inner spacing (distance from header and content) the the border.
         * @param {number} [opts.fill=Theme.fill] - The color of the button background as a hex value.
         * @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
         * @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {object} [opts.headerStyle=Theme.textStyleLarge] - A textstyle object for the styling of the header. See PIXI.TextStyle
         *     for possible options.
         * @param {object} [opts.textStyle=Theme.textStyleSmall] - A textstyle object for the styling of the text. See PIXI.TextStyle
         *     for possible options.
         * @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the popup (which is a rounded rectangle).
         * @param {hiddenCallback} [opts.onHidden] - Executed when the popup gets hidden.
         * @param {boolean} [opts.visible=true] - Is the popup initially visible (property visible)?
         * @param {string} [opts.orientation] - When set to portrait, the popup cannot be displayed in landscape mode. When set
         *     to landscape, the popup cannot be displayed in portrait mode.
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    x: 0,
                    y: 0,
                    header: null, // null or null
                    content: null, // null or String or PIXI.DisplayObject
                    minWidth: 320,
                    minHeight: 130,
                    maxWidth: null,
                    padding: theme.padding,
                    fill: theme.fill,
                    fillAlpha: theme.fillAlpha,
                    stroke: theme.stroke,
                    strokeWidth: theme.strokeWidth,
                    strokeAlpha: theme.strokeAlpha,
                    headerStyle: theme.textStyleLarge,
                    textStyle: theme.textStyleSmall,
                    radius: theme.radius,
                    onHidden: null,
                    visible: true,
                    orientation: null
                },
                opts
            );

            this.id = this.opts.id;

            this.headerStyle = new PIXI.TextStyle(this.opts.headerStyle);
            this.textStyle = new PIXI.TextStyle(this.opts.textStyle);

            if (this.opts.maxWidth) {
                this.headerStyle.wordWrap = true;
                this.headerStyle.wordWrapWidth =
                    this.opts.maxWidth - 2 * this.opts.padding;

                this.textStyle.wordWrap = true;
                this.textStyle.wordWrapWidth =
                    this.opts.maxWidth - 2 * this.opts.padding;
            }

            this.alpha = 0;
            this.visible = this.opts.visible;

            this._header = null;
            this._content = null;

            // position
            this.x = this.opts.x;
            this.y = this.opts.y;

            // padding
            this.innerPadding = this.opts.padding * 1.5;

            // interaction
            //-----------------
            this.interactive = true;
            this.on('added', e => {
                this.show();
            });
        }

        /**
         * Creates the framework and instantiates everything.
         *
         * @private
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        setup() {
            // position
            //-----------------
            this.sy = this.opts.padding;

            // header
            //-----------------
            if (this.opts.header != null) {
                let header = null;

                if (this.opts.header instanceof PIXI.Text) {
                    header = this.opts.header;
                } else if (typeof this.opts.header === 'number') {
                    header = new PIXI.Text(
                        this.opts.header.toString(),
                        this.headerStyle
                    );
                } else {
                    header = new PIXI.Text(this.opts.header, this.headerStyle);
                }

                header.x = this.opts.padding;
                header.y = this.sy;

                this.addChild(header);

                this.sy += header.height;

                this._header = header;
            }

            if (this.opts.header && this.opts.content) {
                this.sy += this.innerPadding;
            }

            // content
            //-----------------
            if (this.opts.content != null) {
                let content = null;

                if (typeof this.opts.content === 'string') {
                    content = new PIXI.Text(this.opts.content, this.textStyle);
                } else if (typeof this.opts.content === 'number') {
                    content = new PIXI.Text(
                        this.opts.content.toString(),
                        this.textStyle
                    );
                } else {
                    content = this.opts.content;
                }

                content.x = this.opts.padding;
                content.y = this.sy;

                this.sy += content.height;

                this.addChild(content);

                this._content = content;
            }

            return this
        }

        /**
         * Should be called to refresh the layout of the popup. Can be used after resizing.
         *
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        layout() {
            // wanted width & wanted height
            //-----------------
            const padding = this.opts.padding;
            const size = this.getInnerSize();
            const width = size.width + 2 * padding;
            const height = size.height + 2 * padding;

            this.wantedWidth = Math.max(width, this.opts.minWidth);
            this.wantedHeight = Math.max(height, this.opts.minHeight);

            if (this.opts.maxWidth) {
                this.wantedWidth = Math.min(this.wantedWidth, this.opts.maxWidth);
            }

            if (this.opts.radius * 2 > this.wantedWidth) {
                this.wantedWidth = this.opts.radius * 2;
            }

            if (this.opts.radius * 2 > this.wantedHeight) {
                this.wantedHeight = this.opts.radius * 2;
            }

            switch (this.opts.orientation) {
                case 'portrait':
                    if (this.wantedWidth > this.wantedHeight) {
                        this.wantedHeight = this.wantedWidth;
                    }
                    break
                case 'landscape':
                    if (this.wantedHeight > this.wantedWidth) {
                        this.wantedWidth = this.wantedHeight;
                    }
                    break
            }

            this.draw();

            return this
        }

        /**
         * Draws the canvas.
         *
         * @private
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        draw() {
            const square =
                Math.round(this.wantedWidth) === Math.round(this.wantedHeight);
            const diameter = Math.round(this.opts.radius * 2);

            this.clear();
            this.lineStyle(
                this.opts.strokeWidth,
                this.opts.stroke,
                this.opts.strokeAlpha
            );
            this.beginFill(this.opts.fill, this.opts.fillAlpha);
            if (square && diameter === this.wantedWidth) {
                this.drawCircle(
                    this.wantedWidth / 2,
                    this.wantedHeight / 2,
                    this.opts.radius
                );
            } else {
                this.drawRoundedRect(
                    0,
                    0,
                    this.wantedWidth,
                    this.wantedHeight,
                    this.opts.radius
                );
            }
            this.endFill();

            return this
        }

        /**
         * Calculates the size of the children of the AbstractPopup.
         * Cannot use getBounds() because it is not updated when children
         * are removed.
         *
         * @private
         * @returns {object} An JavaScript object width the keys width and height.
         */
        getInnerSize() {
            let width = 0;
            let height = 0;

            if (this._header) {
                width = this._header.width;
                height = this._header.height;
            }

            if (this._header && this._content) {
                height += this.innerPadding;
            }

            if (this._content) {
                width = Math.max(width, this._content.width);
                height += this._content.height;
            }

            return { width, height }
        }

        /**
         * Shows the popup (sets his alpha values to 1).
         *
         * @param {callback} [cb] - Executed when show animation was completed.
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        show(cb) {
            TweenLite.to(this, this.theme.fast, {
                alpha: 1,
                onComplete: () => {
                    if (cb) {
                        cb.call(this);
                    }
                }
            });

            return this
        }

        /**
         * Hides the popup (sets his alpha values to 0).
         *
         * @param {callback} [cb] - Executed when hide animation was completed.
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        hide(cb) {
            TweenLite.to(this, this.theme.fast, {
                alpha: 0,
                onComplete: () => {
                    this.visible = false;
                    if (cb) {
                        cb.call(this);
                    }
                }
            });

            if (this.opts.onHidden) {
                this.opts.onHidden.call(this, this);
            }

            return this
        }

        /**
         * Sets or gets the header. The getter always returns a PIXI.Text object. The setter can receive
         * a string, a number or a PIXI.Text object.
         *
         * @member {string|number|PIXI.Text}
         */
        get header() {
            return this._header
        }
        set header(value) {
            if (this._header) {
                this._header.destroy();
            }
            this.opts.header = value;
            this.setup().layout();
        }

        /**
         * Sets or gets the content. The getter always returns an PIXI.DisplayObject. The setter can receive
         * a string, a number or a PIXI.DisplayObject.
         *
         * @member {string|number|PIXI.DisplayObject}
         */
        get content() {
            return this._content
        }
        set content(value) {
            if (this._content) {
                this._content.destroy();
            }
            this.opts.content = value;
            this.setup().layout();
        }
    }

    /**
     * Class that represents a PixiJS Tooltip.
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 900,
     *     height: 250
     * }).setup().run()
     *
     * // Add an DisplayObject to the app
     * const circle = new PIXI.Graphics()
     * circle.beginFill(0x5251a3)
     * circle.drawCircle(50, 50, 40)
     * app.scene.addChild(circle)
     *
     * const tooltip = new Tooltip({
     *     object: circle,
     *     container: app.scene,
     *     content: 'Das Gesetz ist der Freund des Schwachen.'
     * })
     *
     * @class
     * @extends AbstractPopup
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/tooltip.html|DocTest}
     */
    class Tooltip extends AbstractPopup {
        /**
         * Creates an instance of a Tooltip.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the tooltip.
         * @param {number} [opts.minWidth=0] - The minimum width of the tooltip.
         * @param {number} [opts.minHeight=0] - The minimum height of the tooltip.
         * @param {number} [opts.padding=Theme.padding / 2] - The inner spacing of the tooltip.
         * @param {PIXI.DisplayObject} opts.object - The object, where the tooltip should be displayed.
         * @param {PIXI.DisplayObject} [opts.container=object] - The container where the tooltip should be attached to.
         * @param {number} [opts.offsetLeft=8] - The horizontal shift of the tooltip.
         * @param {number} [opts.offsetTop=-8] - The vertical shift of the tooltip.
         * @param {number} [opts.delay=0] - A delay, after which the tooltip should be opened.
         */
        constructor(opts = {}) {
            const theme = Theme.fromString(opts.theme);

            opts = Object.assign(
                {},
                {
                    minWidth: 0,
                    minHeight: 0,
                    padding: theme.padding / 2,
                    object: null,
                    container: null,
                    offsetLeft: 8,
                    offsetTop: -8,
                    delay: 0
                },
                opts
            );

            opts.container = opts.container || opts.object;

            super(opts);

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Tooltip} A reference to the tooltip for chaining.
         */
        setup() {
            super.setup();

            // bind events this
            //-----------------
            this.interactive = true;

            let mouseoverTooltip = false;

            this.on('mouseover', e => {
                mouseoverTooltip = true;
            });

            this.on('mouseout', e => {
                mouseoverTooltip = false;
                if (!mouseoverObject) {
                    this.hide(() => {
                        this.opts.container.removeChild(this);
                    });
                }
            });

            // bind events object
            //-----------------
            const object = this.opts.object;
            object.interactive = true;

            let mouseoverObject = false;

            object.on('mouseover', e => {
                this.timeout = window.setTimeout(() => {
                    mouseoverObject = true;
                    this.visible = true;
                    this.opts.container.addChild(this);
                    this.setPosition(e);
                }, this.opts.delay * 1000);
            });

            object.on('mousemove', e => {
                if (mouseoverObject) {
                    this.setPosition(e);
                }
            });

            object.on('mouseout', e => {
                mouseoverObject = false;
                window.clearTimeout(this.timeout);
                if (!mouseoverTooltip) {
                    this.hide(() => {
                        this.opts.container.removeChild(this);
                    });
                }
            });

            return this
        }

        /**
         * Calculates and sets the position of the tooltip.
         *
         * @private
         * @return {Tooltip} A reference to the tooltip for chaining.
         */
        setPosition(e) {
            const position = e.data.getLocalPosition(this.opts.container);

            this.x = position.x + this.opts.offsetLeft;
            this.y = position.y + this.opts.offsetTop - this.height;

            return this
        }
    }

    /**
     * Class that represents a PixiJS Badge.
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 900,
     *     height: 250
     * }).setup().run()
     *
     * // Add an DisplayObject to the app
     * const circle = new PIXI.Graphics()
     * circle.beginFill(0x5251a3)
     * circle.drawCircle(50, 50, 40)
     * app.scene.addChild(circle)
     *
     * const badge1 = new Badge({
     *     object: circle,
     *     container: app.scene,
     *     content: 'Das Gesetz ist der Freund des Schwachen.'
     * })
     *
     * @class
     * @extends AbstractPopup
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/badge.html|DocTest}
     */
    class Badge extends AbstractPopup {
        /**
         * Creates an instance of a Badge.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the badge.
         * @param {number} [opts.minWidth=0] - The minimum width of the badge.
         * @param {number} [opts.minHeight=0] - The minimum height of the badge.
         * @param {number} [opts.padding=Theme.padding / 2] - The inner spacing of the badge.
         * @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
         *     to display.
         */
        constructor(opts = {}) {
            const theme = Theme.fromString(opts.theme);

            opts = Object.assign(
                {},
                {
                    minWidth: 0,
                    minHeight: 0,
                    padding: theme.padding / 2,
                    tooltip: null
                },
                opts
            );

            super(opts);

            this.tooltip = null;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @override
         * @return {Badge} A reference to the badge for chaining.
         */
        setup() {
            super.setup();

            // tooltip
            //-----------------
            if (this.opts.tooltip) {
                if (typeof this.opts.tooltip === 'string') {
                    this.tooltip = new Tooltip({
                        object: this,
                        content: this.opts.tooltip
                    });
                } else {
                    this.opts.tooltip = Object.assign(
                        {},
                        { object: this },
                        this.opts.tooltip
                    );
                    this.tooltip = new Tooltip(this.opts.tooltip);
                }
            }

            return this
        }

        /**
         * Should be called to refresh the layout of the badge. Can be used after resizing.
         *
         * @override
         * @return {Badge} A reference to the badge for chaining.
         */
        layout() {
            super.layout();

            this.content.x =
                this.width / 2 - this.content.width / 2 - this.opts.strokeWidth / 2;
            this.content.y =
                this.height / 2 -
                this.content.height / 2 -
                this.opts.strokeWidth / 2;

            return this
        }
    }

    class Events$1 {
        static stop(event) {
            event.preventDefault();
            event.stopPropagation();
        }

        static extractPoint(event) {
            switch (event.constructor.name) {
                case 'TouchEvent':
                    for (let i = 0; i < event.targetTouches.length; i++) {
                        let t = event.targetTouches[i];
                        return { x: t.clientX, y: t.clientY }
                    }
                    break
                default:
                    return { x: event.clientX, y: event.clientY }
            }
        }

        static isCaptured(event) {
            if (event.__capturedBy) return true
            return false
        }

        static capturedBy(event, obj) {
            event.__capturedBy = obj;
        }

        static isPointerDown(event) {
            // According to
            // https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
            // pointer events use the buttons feature to represent pressed buttons
            return event.buttons
        }

        static isMouseDown(event) {
            // Attempts to clone the which attribute of events failed in WebKit. May
            // be this is a bug or a security feature. Workaround: we introduce
            // a mouseDownSubstitute attribute that can be assigned to cloned
            // events after instantiation.
            if (Reflect.has(event, 'mouseDownSubstitute'))
                return event.mouseDownSubstitute
            return event.buttons || event.which
        }

        static isSimulatedEvent(event) {
            return Reflect.has(event, 'mouseDownSubstitute')
        }

        static isMouseRightClick(event) {
            return event.buttons || event.which
        }

        static extractTouches(targets) {
            let touches = [];
            for (let i = 0; i < targets.length; i++) {
                let t = targets[i];
                touches.push({
                    targetSelector: this.selector(t.target),
                    identifier: t.identifier,
                    screenX: t.screenX,
                    screenY: t.screenY,
                    clientX: t.clientX,
                    clientY: t.clientY,
                    pageX: t.pageX,
                    pageY: t.pageY
                });
            }
            return touches
        }

        static createTouchList(targets) {
            let touches = [];
            for (let i = 0; i < targets.length; i++) {
                let t = targets[i];
                let touchTarget = document.elementFromPoint(t.pageX, t.pageY);
                let touch = new Touch(
                    undefined,
                    touchTarget,
                    t.identifier,
                    t.pageX,
                    t.pageY,
                    t.screenX,
                    t.screenY
                );
                touches.push(touch);
            }
            return new TouchList(...touches)
        }

        static extractEvent(timestamp, event) {
            let targetSelector = this.selector(event.target);
            let infos = {
                type: event.type,
                time: timestamp,
                constructor: event.constructor,
                data: {
                    targetSelector: targetSelector,
                    view: event.view,
                    mouseDownSubstitute: event.buttons || event.which, // which cannot be cloned directly
                    bubbles: event.bubbles,
                    cancelable: event.cancelable,
                    screenX: event.screenX,
                    screenY: event.screenY,
                    clientX: event.clientX,
                    clientY: event.clientY,
                    layerX: event.layerX,
                    layerY: event.layerY,
                    pageX: event.pageX,
                    pageY: event.pageY,
                    ctrlKey: event.ctrlKey,
                    altKey: event.altKey,
                    shiftKey: event.shiftKey,
                    metaKey: event.metaKey
                }
            };
            if (event.type.startsWith('touch')) {
                // On Safari-WebKit the TouchEvent has layerX, layerY coordinates
                let data = infos.data;
                data.targetTouches = this.extractTouches(event.targetTouches);
                data.changedTouches = this.extractTouches(event.changedTouches);
                data.touches = this.extractTouches(event.touches);
            }
            if (event.type.startsWith('pointer')) {
                let data = infos.data;
                data.pointerId = event.pointerId;
                data.pointerType = event.pointerType;
            }
            if (Events$1.debug) {
                Events$1.extracted.push(this.toLine(event));
            }
            return infos
        }

        static cloneEvent(type, constructor, data) {
            if (type.startsWith('touch')) {
                // We need to find target from layerX, layerY
                //var target = document.querySelector(data.targetSelector)
                // elementFromPoint(data.layerX, data.layerY)
                //data.target = target
                data.targetTouches = this.createTouchList(data.targetTouches);
                data.changedTouches = this.createTouchList(data.changedTouches);
                data.touches = this.createTouchList(data.touches);
            }
            // We need to find target from pageX, pageY which are only
            // available after construction. They seem to getter items.

            let clone = Reflect.construct(constructor, [type, data]);
            clone.mouseDownSubstitute = data.mouseDownSubstitute;
            return clone
        }

        static simulateEvent(type, constructor, data) {
            data.target = document.querySelector(data.targetSelector);
            let clone = this.cloneEvent(type, constructor, data);
            if (data.target != null) {
                data.target.dispatchEvent(clone);
            }
            if (Events$1.debug) {
                Events$1.simulated.push(this.toLine(clone));
            }
        }

        static toLine(event) {
            return `${event.type} #${event.target.id} ${event.clientX} ${event.clientY}`
            let result = event.type;
            let selector = this.selector(event.target);
            result += ' selector: ' + selector;
            if (event.target != document.querySelector(selector))
                console.log('Cannot resolve', selector);
            let keys = ['layerX', 'layerY', 'pageX', 'pageY', 'clientX', 'clientY'];
            for (let key of keys) {
                try {
                    result += ' ' + key + ':' + event[key];
                } catch (e) {
                    console.log('Invalid key: ' + key);
                }
            }
            return result
        }

        static compareExtractedWithSimulated() {
            if (this.extracted.length != this.simulated.length) {
                alert(
                    'Unequal length of extracted [' +
                        this.extracted.length +
                        '] and simulated events [' +
                        this.simulated.length +
                        '].'
                );
            } else {
                for (let i = 0; i < this.extracted.length; i++) {
                    var extracted = this.extracted[i];
                    var simulated = this.simulated[i];
                    if (extracted != simulated) {
                        console.log('Events differ:' + extracted + '|' + simulated);
                    }
                }
            }
        }

        static selector(context) {
            return OptimalSelect.select(context)
        }

        static reset() {
            this.extracted = [];
            this.simulated = [];
        }

        static resetSimulated() {
            this.simulated = [];
        }

        static showExtractedEvents(event) {
            if (!event.shiftKey) {
                return
            }
            if (this.popup == null) {
                let element = document.createElement('div');
                Elements.setStyle(element, {
                    position: 'absolute',
                    width: '480px',
                    height: '640px',
                    overflow: 'auto',
                    backgroundColor: 'lightgray'
                });
                document.body.appendChild(element);
                this.popup = element;
            }
            this.popup.innerHTML = '';
            for (let line of this.extracted) {
                let div = document.createElement('div');
                div.innerHTML = line;
                this.popup.appendChild(div);
            }
            let div = document.createElement('div');
            div.innerHTML = '------------ Simulated -----------';
            this.popup.appendChild(div);
            for (let line of this.simulated) {
                let div = document.createElement('div');
                div.innerHTML = line;
                this.popup.appendChild(div);
            }
            Elements.setStyle(this.popup, {
                left: event.clientX + 'px',
                top: event.clientY + 'px'
            });
        }
    }

    Events$1.popup = null;
    Events$1.debug = true;
    Events$1.extracted = [];
    Events$1.simulated = [];
    Events$1.simulationRunning = false;

    /* global PIXI TweenLite */

    /**
     * Callback for the button action.
     *
     * @callback actionCallback
     * @param {object} event - The event object.
     * @param {Button} button - A reference to the button (also this refers to the button).
     */

    /**
     * Callback for the button beforeAction.
     *
     * @callback beforeActionCallback
     * @param {object} event - The event object.
     * @param {Button} button - A reference to the button (also this refers to the button).
     */

    /**
     * Callback for the button afterAction.
     *
     * @callback afterActionCallback
     * @param {object} event - The event object.
     * @param {Button} button - A reference to the button (also this refers to the button).
     */

    /**
     * Class that represents a PixiJS Button.
     *
     * @example
     * // Create the button
     * const button = new Button({
     *     label: 'My Button',
     *     action: () => console.log('Button was clicked')
     * })
     *
     * // Add the button to a DisplayObject
     * app.scene.addChild(button)
     *
     * @class
     * @extends PIXI.Container
     * @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/button.html|DocTest}
     */
    class Button extends PIXI.Container {
        /**
         * Creates an instance of a Button.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the button.
         * @param {number} [opts.id=auto generated] - The id of the button.
         * @param {string} [opts.label] - The label of the button.
         * @param {number} [opts.x=0] - The x position of the button. Can be also set after creation with button.x = 0.
         * @param {number} [opts.y=0] - The y position of the button. Can be also set after creation with button.y = 0.
         * @param {string|Theme} [opts.theme=dark] - The theme to use for this button. Possible values are dark, light, red
         *     or a Theme object.
         * @param {number} [opts.minWidth=44] - The minimum width of the button.
         * @param {number} [opts.minHeight=44] - The minimum height of the button.
         * @param {number} [opts.padding=Theme.padding] - The inner spacing (distance from icon and/or label) to the border.
         * @param {string|PIXI.DisplayObject} [opts.icon] - The icon of the button. Can be a predefined one, an URL or an PIXI.DisplayObject.
         * @param {string|PIXI.DisplayObject} [opts.iconActive=icon] - The icon of the button when activated. Can be a predefined one, an URL or an PIXI.DisplayObject.
         * @param {string} [opts.iconPosition=left] - The position of the icon in relation to the label. Can be left or right.
         * @param {number} [opts.iconColor=Theme.iconColor] - The color of the icon (set by the tint property) as a hex value.
         * @param {number} [opts.iconColorActive=Theme.iconColorActive] - The color of the icon when activated.
         * @param {number} [opts.fill=Theme.fill] - The color of the button background as a hex value.
         * @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
         * @param {number} [opts.fillActive=Theme.fillActive] - The color of the button background when activated.
         * @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
         * @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
         * @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - The width of the border in pixel when activated.
         * @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
         * @param {object} [opts.textStyle=Theme.textStyle] - A textstyle object for the styling of the label. See PIXI.TextStyle
         *     for possible options.
         * @param {number} [opts.textStyleActive=Theme.textStyleActive] - A textstyle object for the styling of the label when the
         *     button is activated. See PIXI.TextStyle for possible options.
         * @param {string} [opts.style=default] - A shortcut for styling options. Possible values are default, link.
         * @param {number} [opts.radius=Theme.radius] - The radius of the four corners of the button (which is a rounded rectangle).
         * @param {boolean} [opts.disabled=false] - Is the button disabled? When disabled, the button has a lower alpha value
         *     and cannot be clicked (interactive is set to false).
         * @param {boolean} [opts.active=false] - Is the button initially active?
         * @param {actionCallback} [opts.action] - Executed when the button was triggered (by pointerup).
         * @param {beforeActionCallback} [opts.beforeAction] - Executed before the main action is triggered.
         * @param {afterActionCallback} [opts.afterAction] - Executed after the main action was triggered.
         * @param {string} [opts.type=default] - The type of the button. Can be default or checkbox. When the type is
         *     checkbox, the active state is toggled automatically.
         * @param {string} [opts.align=center] - The horizontal position of the label and the icon. Possible values are
         *     left, center and right. Only affects the style when the minWidth is bigger than the width of the icon and label.
         * @param {string} [opts.verticalAlign=middle] - The vertical position of the label and the icon. Possible values are
         *     top, middle and button. Only affects the style when the minHeight is bigger than the height of the icon and label.
         * @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
         *     to display.
         * @param {string|object} [opts.badge] - A string for the label of the badge or an object to configure the badge to display.
         *     If the parameter is an object, all badge options can be set plus the following:
         * @param {string} [opts.badge.align=right] - The horizontal alignment of the badge. Possible values: left, center, right
         * @param {string} [opts.badge.verticalAlign=top] - The vertical alignment of the badge. Possible values: top, middle, bottom
         * @param {number} [opts.badge.offsetLeft=0] - The horizontal shift of the badge.
         * @param {number} [opts.badge.offsetTop=0] - The vertical shift of the badge.
         * @param {boolean} [opts.visible=true] - Is the button initially visible (property visible)?
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    label: null,
                    x: 0,
                    y: 0,
                    minWidth: 44,
                    minHeight: 44,
                    padding: theme.padding,
                    icon: undefined,
                    iconActive: undefined,
                    iconPosition: 'left',
                    iconColor: theme.iconColor,
                    iconColorActive: theme.iconColorActive,
                    fill: theme.fill,
                    fillAlpha: theme.fillAlpha,
                    fillActive: theme.fillActive,
                    fillActiveAlpha: theme.fillActiveAlpha,
                    stroke: theme.stroke,
                    strokeWidth: theme.strokeWidth,
                    strokeAlpha: theme.strokeAlpha,
                    strokeActive: theme.strokeActive,
                    strokeActiveWidth: theme.strokeActiveWidth,
                    strokeActiveAlpha: theme.strokeActiveAlpha,
                    textStyle: theme.textStyle,
                    textStyleActive: theme.textStyleActive,
                    style: 'default',
                    radius: theme.radius,
                    disabled: false,
                    active: false,
                    action: null,
                    beforeAction: null,
                    afterAction: null,
                    type: 'default',
                    align: 'center',
                    verticalAlign: 'middle',
                    tooltip: null,
                    badge: null,
                    visible: true
                },
                opts
            );

            this.id = this.opts.id;

            if (
                typeof this.opts.icon === 'undefined' &&
                typeof this.opts.iconActive !== 'undefined'
            ) {
                this.opts.icon = this.opts.iconActive;
            } else if (
                typeof this.opts.icon !== 'undefined' &&
                typeof this.opts.iconActive === 'undefined'
            ) {
                this.opts.iconActive = this.opts.icon;
            }

            if (this.opts.style === 'link') {
                Object.assign(this.opts, {
                    strokeAlpha: 0,
                    strokeActiveAlpha: 0,
                    fillAlpha: 0,
                    fillActiveAlpha: 0
                });
            }

            this._active = null;
            this._disabled = null;

            this.iconInactive = null;
            this.iconActive = null;
            this.text = null;

            this.button = null;
            this.content = null;

            this.tooltip = null;
            this.badge = null;

            this.visible = this.opts.visible;

            // setup
            //-----------------
            this.setup();
        }

        /**
         * Captures an event to inform InteractionMapper about processed events.
         *
         * @param {event|PIXI.InteractionEvent} event - The PIXI event to capture.
         */
        capture(event) {
            Events$1.capturedBy(event.data.originalEvent, this);
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Button} A reference to the button for chaining.
         */
        setup() {
            // Button
            //-----------------
            let button = new PIXI.Graphics();
            this.button = button;
            this.addChild(button);

            // Content
            //-----------------
            let content = new PIXI.Container();
            this.content = content;
            this.addChild(content);

            // Text
            //-----------------
            if (this.opts.label) {
                this.text = new PIXI.Text(this.opts.label, this.opts.textStyle);
            }

            // Icon
            //-----------------
            if (this.opts.icon) {
                this.iconInactive = this.loadIcon(
                    this.opts.icon,
                    this.opts.iconColor
                );
            }

            if (this.opts.iconActive) {
                this.iconActive = this.loadIcon(
                    this.opts.iconActive,
                    this.opts.iconColorActive
                );
            }

            // interaction
            //-----------------
            this.button.on('pointerover', e => {
                this.capture(e);
                TweenLite.to([this.button, this.content], this.theme.fast, {
                    alpha: 0.83,
                    overwrite: 'none'
                });
            });

            this.button.on('pointermove', e => {
                this.capture(e);
            });

            this.button.on('pointerout', e => {
                this.capture(e);
                TweenLite.to([this.button, this.content], this.theme.fast, {
                    alpha: 1,
                    overwrite: 'none'
                });
            });

            // eslint-disable-next-line no-unused-vars
            this.button.on('pointerdown', e => {
                //this.capture(e)
                TweenLite.to([this.button, this.content], this.theme.fast, {
                    alpha: 0.7,
                    overwrite: 'none'
                });
            });

            this.button.on('pointerup', e => {
                this.capture(e);
                if (this.opts.beforeAction) {
                    this.opts.beforeAction.call(this, e, this);
                }

                if (this.opts.action) {
                    this.opts.action.call(this, e, this);
                }

                TweenLite.to([this.button, this.content], this.theme.fast, {
                    alpha: 0.83,
                    overwrite: 'none'
                });

                if (this.opts.type === 'checkbox') {
                    this.active = !this.active;
                }

                if (this.opts.afterAction) {
                    this.opts.afterAction.call(this, e, this);
                }
            });

            // disabled
            //-----------------
            this.disabled = this.opts.disabled;

            // active
            //-----------------
            this.active = this.opts.active; // calls .layout()

            // tooltip
            //-----------------
            if (this.opts.tooltip) {
                if (typeof this.opts.tooltip === 'string') {
                    this.tooltip = new Tooltip({
                        object: this,
                        content: this.opts.tooltip
                    });
                } else {
                    this.opts.tooltip = Object.assign(
                        {},
                        { object: this },
                        this.opts.tooltip
                    );
                    this.tooltip = new Tooltip(this.opts.tooltip);
                }
            }

            // badge
            //-----------------
            if (this.opts.badge) {
                let opts = Object.assign(
                    {},
                    {
                        align: 'right',
                        verticalAlign: 'top',
                        offsetLeft: 0,
                        offsetTop: 0
                    }
                );
                if (typeof this.opts.badge === 'string') {
                    opts = Object.assign(opts, { content: this.opts.badge });
                } else {
                    opts = Object.assign(opts, this.opts.badge);
                }

                const badge = new Badge(opts);

                switch (opts.align) {
                    case 'left':
                        badge.x = this.x - badge.width / 2 + opts.offsetLeft;
                        break
                    case 'center':
                        badge.x =
                            this.x +
                            this.width / 2 -
                            badge.width / 2 +
                            opts.offsetLeft;
                        break
                    case 'right':
                        badge.x =
                            this.x + this.width - badge.width / 2 + opts.offsetLeft;
                }

                switch (opts.verticalAlign) {
                    case 'top':
                        badge.y = this.y - badge.height / 2 + opts.offsetTop;
                        break
                    case 'middle':
                        badge.y =
                            this.y +
                            this.height / 2 -
                            badge.height / 2 +
                            opts.offsetTop;
                        break
                    case 'bottom':
                        badge.y =
                            this.y + this.height - badge.height / 2 + opts.offsetTop;
                }

                this.addChild(badge);

                this.badge = badge;
            }

            // set position
            //-----------------
            this.position.set(this.opts.x, this.opts.y);

            return this
        }

        /**
         * Should be called to refresh the layout of the button. Can be used after resizing.
         *
         * @return {Button} A reference to the button for chaining.
         */
        layout() {
            // Clear content
            //-----------------
            this.removeChild(this.content);
            this.content = new PIXI.Container();
            this.addChild(this.content);

            // Set the icon
            //-----------------
            let icon = null;

            if (!this.active && this.iconInactive) {
                icon = this.iconInactive;
            } else if (this.active && this.iconActive) {
                icon = this.iconActive;
            }

            // Set the text
            //-----------------
            if (this.text) {
                this.text.position.set(0, 0);
            }

            // Width and Height
            //-----------------
            let width = 0;
            if (icon && this.text) {
                width = icon.width + this.text.width + 3 * this.opts.padding;
            } else if (icon) {
                width = icon.width + 2 * this.opts.padding;
            } else if (this.text) {
                width = this.text.width + 2 * this.opts.padding;
            }

            if (width < this.opts.minWidth) {
                width = this.opts.minWidth;
            }

            let height = 0;
            if (icon) {
                height = icon.height + 2 * this.opts.padding;
            } else if (this.text) {
                height = this.text.height + 2 * this.opts.padding;
            }

            if (height < this.opts.minHeight) {
                height = this.opts.minHeight;
            }

            this._width = width;
            this._height = height;

            // Position icon and text
            //-----------------
            if (icon && this.text) {
                if (this.opts.iconPosition === 'right') {
                    icon.x = this.text.width + this.opts.padding;
                } else {
                    this.text.x = icon.width + this.opts.padding;
                }
                this.content.addChild(icon, this.text);
            } else if (icon) {
                this.content.addChild(icon);
            } else if (this.text) {
                this.content.addChild(this.text);
            }

            this.layoutInnerContent();
            this.layoutContent();

            this.icon = icon;

            // draw
            //-----------------
            this.draw();

            return this
        }

        /**
         * Calculates the positions of the content children (icon and/or text).
         *
         * @private
         * @return {Button} A reference to the button for chaining.
         */
        layoutInnerContent() {
            for (let child of this.content.children) {
                switch (this.opts.verticalAlign) {
                    case 'top':
                        child.y = 0;
                        break
                    case 'middle':
                        child.y = this.content.height / 2 - child.height / 2;
                        break
                    case 'bottom':
                        child.y = this.content.height - child.height;
                        break
                }
            }

            return this
        }

        /**
         * Sets the horizontal and vertical position of the content.
         * Uses the option keys "align" and "verticalAlign".
         *
         * @private
         * @return {Button} A reference to the button for chaining.
         */
        layoutContent() {
            switch (this.opts.align) {
                case 'left':
                    this.content.x = this.opts.padding;
                    break
                case 'center':
                    this.content.x = (this._width - this.content.width) / 2;
                    break
                case 'right':
                    this.content.x =
                        this._width - this.opts.padding - this.content.width;
                    break
            }

            switch (this.opts.verticalAlign) {
                case 'top':
                    this.content.y = this.opts.padding;
                    break
                case 'middle':
                    this.content.y = (this._height - this.content.height) / 2;
                    break
                case 'bottom':
                    this.content.y =
                        this._height - this.opts.padding - this.content.height;
                    break
            }

            return this
        }

        /**
         * Draws the canvas.
         *
         * @private
         * @return {Button} A reference to the button for chaining.
         */
        draw() {
            this.button.clear();
            if (this.active) {
                this.button.lineStyle(
                    this.opts.strokeActiveWidth,
                    this.opts.strokeActive,
                    this.opts.strokeActiveAlpha
                );
                this.button.beginFill(
                    this.opts.fillActive,
                    this.opts.fillActiveAlpha
                );
            } else {
                this.button.lineStyle(
                    this.opts.strokeWidth,
                    this.opts.stroke,
                    this.opts.strokeAlpha
                );
                this.button.beginFill(this.opts.fill, this.opts.fillAlpha);
            }
            this.button.drawRoundedRect(
                0,
                0,
                this._width,
                this._height,
                this.opts.radius
            );
            this.button.endFill();

            return this
        }

        /**
         * Gets or sets the active state.
         *
         * @member {boolean}
         */
        get active() {
            return this._active
        }
        set active(value) {
            this._active = value;

            if (this._active) {
                if (this.text) {
                    this.text.style = this.opts.textStyleActive;
                }
            } else {
                if (this.text) {
                    this.text.style = this.opts.textStyle;
                }
            }

            this.layout();
        }

        /**
         * Gets or sets the disabled state. When disabled, the button cannot be clicked.
         *
         * @member {boolean}
         */
        get disabled() {
            return this._disabled
        }
        set disabled(value) {
            this._disabled = value;

            if (this._disabled) {
                this.button.interactive = false;
                this.button.buttonMode = false;
                this.button.alpha = 0.5;
                if (this.icon) {
                    this.icon.alpha = 0.5;
                }
                if (this.text) {
                    this.text.alpha = 0.5;
                }
            } else {
                this.button.interactive = true;
                this.button.buttonMode = true;
                this.button.alpha = 1;
                if (this.icon) {
                    this.icon.alpha = 1;
                }
                if (this.text) {
                    this.text.alpha = 1;
                }
            }
        }

        /**
         * Shows the button (sets his alpha values to 1).
         *
         * @return {Button} A reference to the button for chaining.
         */
        show() {
            this.opts.strokeAlpha = 1;
            this.opts.strokeActiveAlpha = 1;
            this.opts.fillAlpha = 1;
            this.opts.fillActiveAlpha = 1;

            this.layout();

            return this
        }

        /**
         * Hides the button (sets his alpha values to 0).
         *
         * @return {Button} A reference to the button for chaining.
         */
        hide() {
            this.opts.strokeAlpha = 0;
            this.opts.strokeActiveAlpha = 0;
            this.opts.fillAlpha = 0;
            this.opts.fillActiveAlpha = 0;

            this.layout();

            return this
        }

        /**
         * Loads an icon
         *
         * @private
         * @param {string|PIXI.DisplayObject} icon - The icon to load.
         * @param {number} color - The color of the icon (if not an PIXI.DisplayObject).
         * @return {PIXI.DisplayObject} Return the icon as an PIXI.DisplayObject.
         */
        loadIcon(icon, color) {
            let displayObject = null;

            if (icon instanceof PIXI.DisplayObject) {
                displayObject = icon;
            } else {
                let size = 17;
                if (this.text) {
                    size = this.text.height;
                } else if (this.opts.minHeight) {
                    size = this.opts.minHeight - 2 * this.opts.padding;
                }

                const url = Button.iconIsUrl(icon)
                    ? icon
                    : `../../assets/icons/${icon}.png`;
                const iconTexture = PIXI.Texture.fromImage(url, true);

                const sprite = new PIXI.Sprite(iconTexture);
                sprite.tint = color;
                sprite.width = size;
                sprite.height = size;

                displayObject = sprite;
            }

            return displayObject
        }

        /**
         * Tests if an icon string is an url.
         *
         * @private
         * @static
         * @param {string} url - The url to test.
         * @return {boolean} true if the url is an url to an image.
         */
        static iconIsUrl(url) {
            return /\.(png|svg|gif|jpg|jpeg|tif|tiff)$/i.test(url)
        }

        /**
         * Gets or sets the color of the current icon (no matter how the status is). Changing the color, changes
         * the tint property of the icon sprite.
         *
         * @member {number}
         */
        get iconColor() {
            return this.icon ? this.icon.tint : null
        }
        set iconColor(value) {
            if (this.icon) {
                this.icon.tint = value;
            }
        }
    }

    /**
     * Class that represents a PixiJS ButtonGroup.
     *
     * @example
     * // Create the button group
     * const buttonGroup = new ButtonGroup({
     *     buttons: [
     *         {label: 'Button 1', action: event => console.log(event)},
     *         {label: 'Button 2', action: event => console.log(event)},
     *         {label: 'Button 3', action: event => console.log(event)}
     *     ],
     *     minWidth: 100
     * })
     *
     * // Add the button group to a DisplayObject
     * app.scene.addChild(buttonGroup)
     *
     * @class
     * @extends PIXI.Graphics
     * @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/buttongroup.html|DocTest}
     */
    class ButtonGroup extends PIXI.Graphics {
        /**
         * Creates an instance of a ButtonGroup.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the button group.
         * @param {number} [opts.id=auto generated] - The id of the button group.
         * @param {number} [opts.x=0] - The x position of the button group. Can be also set after creation with buttonGroup.x = 0.
         * @param {number} [opts.y=0] - The y position of the button group. Can be also set after creation with buttonGroup.y = 0.
         * @param {object[]} [opts.buttons=[]] - An array of the buttons of the button group. One item of the array (one object)
         *     can have exactly the same properties as an Button object when instantiating a Button. If a property of the button group
         *     conflicts with a property of a button object, the value from the button object will be used.
         * @param {string|Theme=} [opts.theme=dark] - The theme to use for this button group. Possible values are dark, light, red
         *     or a Theme object.
         * @param {number} [opts.minWidth=44] - Button: The minimum width of one button.
         * @param {number} [opts.minHeight=44] - Button: The minimum height of one button.
         * @param {number} [opts.padding=Theme.padding] - Button: The inner spacing (distance from icon and/or label) the the border.
         * @param {number} [opts.margin=Theme.margin] - The outer spacing (distance from one button to the previous/next button).
         * @param {string} [opts.iconPosition=left] - Button: The position of the icon in relation to the label. Can be left or right.
         * @param {number} [opts.iconColor=Theme.iconColor] - Button: The color of the icon (set by the tint property) as a hex value.
         * @param {number} [opts.iconColorActive=Theme.iconColorActive] - Button: The color of the icon when activated.
         * @param {number} [opts.fill=Theme.fill] - Button: The color of the button background as a hex value.
         * @param {number} [opts.fillAlpha=Theme.fillAlpha] - Button: The alpha value of the background.
         * @param {number} [opts.fillActive=Theme.fillActive] - Button: The color of the button background when activated.
         * @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - Button: The alpha value of the background when activated.
         * @param {number} [opts.stroke=Theme.stroke] - Button: The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=Theme.strokeWidth] - Button: The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - Button: The alpha value of the border.
         * @param {number} [opts.strokeActive=Theme.strokeActive] - Button: The color of the border when activated.
         * @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - Button: The width of the border in pixel when activated.
         * @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - Button: The alpha value of the border when activated.
         * @param {object} [opts.textStyle=Theme.textStyle] - Button: A textstyle object for the styling of the label. See PIXI.TextStyle
         *     for possible options.
         * @param {number} [opts.textStyleActive=Theme.textStyleActive] - Button: A textstyle object for the styling of the label when the
         *     button is activated. See PIXI.TextStyle for possible options.
         * @param {string} [opts.style=default] - A shortcut for styling options. Possible values are default, link.
         * @param {number} [opts.radius=Theme.radius] - Button: The radius of the four corners of the button (which is a rounded rectangle).
         * @param {boolean} [opts.disabled=false] - Is the button group disabled? When disabled, the button group has a lower alpha value
         *     and cannot be clicked (interactive of every button is set to false).
         * @param {string} [opts.type=default] - The type of the button group. Can be default, checkbox or radio. When the type is
         *     checkbox, the active state is toggled for each button automatically. When the type is radio, only one button can
         *     be activated at the same time.
         * @param {string} [opts.orientation=horizontal] - The orientation of the button group. Can be horizontal or vertical.
         * @param {string} [opts.align=center] - Button: The horizontal position of the label and the icon. Possible values are
         *     left, center and right. Only affects the style when the minWidth is bigger than the width of the icon and label.
         * @param {string} [opts.verticalAlign=middle] - Button: The vertical position of the label and the icon. Possible values are
         *     top, middle and bottom. Only affects the style when the minHeight is bigger than the height of the icon and label.
         * @param {boolean} [opts.visible=true] - Is the button group initially visible (property visible)?
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    x: 0,
                    y: 0,
                    buttons: [],
                    minWidth: 44,
                    minHeight: 44,
                    padding: theme.padding,
                    margin: theme.margin,
                    iconPosition: 'left', // left, right
                    iconColor: theme.iconColor,
                    iconColorActive: theme.iconColorActive,
                    fill: theme.fill,
                    fillAlpha: theme.fillAlpha,
                    fillActive: theme.fillActive,
                    fillActiveAlpha: theme.fillActiveAlpha,
                    stroke: theme.stroke,
                    strokeWidth: theme.strokeWidth,
                    strokeAlpha: theme.strokeAlpha,
                    strokeActive: theme.strokeActive,
                    strokeActiveWidth: theme.strokeActiveWidth,
                    strokeActiveAlpha: theme.strokeActiveAlpha,
                    textStyle: theme.textStyle,
                    textStyleActive: theme.textStyleActive,
                    style: 'default',
                    radius: theme.radius,
                    disabled: null,
                    type: 'default', // default, checkbox, radio
                    orientation: 'horizontal',
                    align: 'center', // left, center, right
                    verticalAlign: 'middle', // top, middle, bottom
                    visible: true
                },
                opts
            );

            this.buttons = [];

            this._disabled = null;

            this.visible = this.opts.visible;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {ButtonGroup} A reference to the button group for chaining.
         */
        setup() {
            // Buttons
            //-----------------
            let position = 0;

            for (let it of this.opts.buttons) {
                delete it.x;
                delete it.y;

                if (this.opts.orientation === 'horizontal') {
                    it.x = position;
                } else {
                    it.y = position;
                }

                it.theme = it.theme || this.opts.theme;
                it.minWidth = it.minWidth || this.opts.minWidth;
                it.minHeight = it.minHeight || this.opts.minHeight;
                it.padding = it.padding || this.opts.padding;
                it.iconPosition = it.iconPosition || this.opts.iconPosition;
                it.iconColor = it.iconColor || this.opts.iconColor;
                it.iconColorActive = it.iconColorActive || this.opts.iconColorActive;
                it.fill = it.fill || this.opts.fill;
                it.fillAlpha = it.fillAlpha || this.opts.fillAlpha;
                it.fillActive = it.fillActive || this.opts.fillActive;
                it.fillActiveAlpha = it.fillActiveAlpha || this.opts.fillActiveAlpha;
                it.stroke = it.stroke || this.opts.stroke;
                it.strokeWidth =
                    it.strokeWidth != null ? it.strokeWidth : this.opts.strokeWidth;
                it.strokeAlpha =
                    it.strokeAlpha != null ? it.strokeAlpha : this.opts.strokeAlpha;
                it.strokeActive = it.strokeActive || this.opts.strokeActive;
                it.strokeActiveWidth =
                    it.strokeActiveWidth != null
                        ? it.strokeActiveWidth
                        : this.opts.strokeActiveWidth;
                it.strokeActiveAlpha =
                    it.strokeActiveAlpha != null
                        ? it.strokeActiveAlpha
                        : this.opts.strokeActiveAlpha;
                it.textStyle = it.textStyle || this.opts.textStyle;
                it.textStyleActive = it.textStyleActive || this.opts.textStyleActive;
                it.style = it.style || this.opts.style;
                it.radius = it.radius != null ? it.radius : this.opts.radius;
                if (!it.type) {
                    switch (this.opts.type) {
                        case 'checkbox':
                            it.type = this.opts.type;
                            break
                        default:
                            it.type = 'default';
                            break
                    }
                }
                //it.type = it.type || this.opts.type || 'default'
                it.align = it.align || this.opts.align;
                it.verticalAlign = it.verticalAlign || this.opts.verticalAlign;
                it.afterAction = (event, button) => {
                    if (
                        this.opts.type === 'radio' &&
                        button.opts.type === 'default'
                    ) {
                        this.buttons.forEach(it => {
                            if (it.opts.type === 'default') {
                                it.active = false;
                            }
                        });

                        if (button.opts.type === 'default') {
                            button.active = true;
                        }
                    }
                };

                if (it.tooltip) {
                    if (typeof it.tooltip === 'string') {
                        it.tooltip = { content: it.tooltip, container: this };
                    } else {
                        it.tooltip = Object.assign(
                            {},
                            { container: this },
                            it.tooltip
                        );
                    }
                }

                let button = new Button(it);

                this.addChild(button);
                this.buttons.push(button);

                position +=
                    (this.opts.orientation === 'horizontal'
                        ? button.width
                        : button.height) + this.opts.margin;
            }

            if (this.opts.orientation === 'vertical') {
                const maxWidth = this.getMaxButtonWidth();

                this.buttons.forEach(it => {
                    it.opts.minWidth = maxWidth;
                    it.layout();
                });
            }

            // disabled
            //-----------------
            if (this.opts.disabled != null) {
                this.disabled = this.opts.disabled;
            }

            return this
        }

        /**
         * Should be called to refresh the layout of the button group. Can be used after resizing.
         *
         * @return {ButtonGroup} A reference to the button group for chaining.
         */
        layout() {
            // set position
            //-----------------
            this.position.set(this.opts.x, this.opts.y);

            // draw
            //-----------------
            this.draw();

            return this
        }

        /**
         * Draws the canvas.
         *
         * @private
         * @return {ButtonGroup} A reference to the button group for chaining.
         */
        draw() {
            if (this.opts.margin === 0) {
                this.buttons.forEach(it => it.hide());

                this.clear();
                this.lineStyle(
                    this.opts.strokeWidth,
                    this.opts.stroke,
                    this.opts.strokeAlpha
                );
                this.beginFill(this.opts.fill, this.opts.fillAlpha);
                this.drawRoundedRect(
                    0,
                    0,
                    this.width,
                    this.height,
                    this.opts.radius
                );

                // Draw borders
                this.lineStyle(
                    this.opts.strokeWidth,
                    this.opts.stroke,
                    this.opts.strokeAlpha / 2
                );

                this.buttons.forEach((it, i) => {
                    if (i > 0) {
                        this.moveTo(it.x, it.y);

                        if (this.opts.orientation === 'horizontal') {
                            this.lineTo(it.x, it.height);
                        } else {
                            this.lineTo(it.width, it.y);
                        }
                    }
                });

                this.endFill();
            }

            return this
        }

        /**
         * Gets or sets the disabled state. When disabled, no button of the button group can be clicked.
         *
         * @member {boolean}
         */
        get disabled() {
            return this._disabled
        }

        set disabled(value) {
            this._disabled = value;

            this.buttons.forEach(it => (it.disabled = value));
        }

        /**
         * Searches all buttons of the button group and returns the maximum width of one button.
         *
         * @private
         * @return {number} The maximum with of a button of the button group.
         */
        getMaxButtonWidth() {
            let widths = this.buttons.map(it => it.width);

            return Math.max(...widths)
        }

        /**
         * Shows the button group (sets his alpha value to 1).
         *
         * @return {ButtonGroup} A reference to the button group for chaining.
         */
        show() {
            this.alpha = 1;

            return this
        }

        /**
         * Hides the button group (sets his alpha value to 0).
         *
         * @return {ButtonGroup} A reference to the button group for chaining.
         */
        hide() {
            this.alpha = 0;

            return this
        }
    }

    /**
     * Class that represents a PixiJS InteractivePopup.
     * The class is used for various other Popup-like classes
     * like Popup, Message...
     *
     * @class
     * @abstract
     * @extends AbstractPopup
     */
    class InteractivePopup extends AbstractPopup {
        /**
         * Creates an instance of an InteractivePopup (only for internal use).
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the popup.
         * @param {boolean} [opts.closeOnPopup=false] - Should the popup be closed when the user clicks on the popup?
         * @param {boolean} [opts.closeButton=true] - Should a close button be displayed on the upper right corner?
         * @param {object} [opts.button] - A Button object to be display on the lower right corner.
         * @param {object} [opts.buttonGroup] - A ButtonGroup object to be displayed on the lower right corner.
         */
        constructor(opts = {}) {
            opts = Object.assign(
                {},
                {
                    closeOnPopup: false,
                    closeButton: true,
                    button: null,
                    buttonGroup: null
                },
                opts
            );

            super(opts);

            this._closeButton = null;
            this._buttons = null;

            // padding
            this.smallPadding = this.opts.padding / 2;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates the framework and instantiates everything.
         *
         * @private
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        setup() {
            super.setup();

            // interaction
            //-----------------
            this.on('pointerup', e => {
                if (this.opts.closeOnPopup) {
                    this.hide();
                } else {
                    e.stopPropagation();
                }
            });

            // closeButton
            //-----------------
            if (this.opts.closeButton) {
                let closeButton = PIXI.Sprite.fromImage(
                    '../../assets/icons/close.png',
                    true
                );
                closeButton.width = this.headerStyle.fontSize;
                closeButton.height = closeButton.width;
                closeButton.tint = this.theme.color2;
                // This is needed, because the closeButton belongs to the content. The popup must resize with the closeButton.
                if (this._header) {
                    closeButton.x = this._header.width + this.innerPadding;
                } else if (this._content) {
                    closeButton.x = this._content.width + this.innerPadding;
                }

                closeButton.interactive = true;
                closeButton.buttonMode = true;
                closeButton.on('pointerdown', e => {
                    this.hide();
                });

                this._closeButton = closeButton;
                this.addChild(closeButton);

                // maxWidth is set and a closeButton should be displayed
                //-----------------
                if (this.opts.maxWidth) {
                    const wordWrapWidth =
                        this.opts.maxWidth -
                        2 * this.opts.padding -
                        this.smallPadding -
                        this._closeButton.width;
                    if (this._header) {
                        this.headerStyle.wordWrapWidth = wordWrapWidth;
                    } else if (this._content) {
                        this.textStyle.wordWrapWidth = wordWrapWidth;
                    }
                }
            }

            // buttons
            //-----------------
            if (this.opts.button || this.opts.buttonGroup) {
                if (this.opts.button) {
                    this._buttons = new Button(
                        Object.assign(
                            { textStyle: this.theme.textStyleSmall },
                            this.opts.button
                        )
                    );
                } else {
                    this._buttons = new ButtonGroup(
                        Object.assign(
                            { textStyle: this.theme.textStyleSmall },
                            this.opts.buttonGroup
                        )
                    );
                }
                this.addChild(this._buttons);

                this._buttons.y = this.innerPadding + this.sy;
            }

            return this
        }

        /**
         * Should be called to refresh the layout of the popup. Can be used after resizing.
         *
         * @return {AbstractPopup} A reference to the popup for chaining.
         */
        layout() {
            super.layout();

            // closeButton
            //-----------------
            if (this.opts.closeButton) {
                this._closeButton.x =
                    this.wantedWidth - this.smallPadding - this._closeButton.width;
                this._closeButton.y = this.smallPadding;
            }

            // buttons
            //-----------------
            if (this._buttons) {
                this._buttons.x =
                    this.wantedWidth - this.opts.padding - this._buttons.width;
                this._buttons.y =
                    this.wantedHeight - this.opts.padding - this._buttons.height;
            }

            return this
        }

        /**
         * Calculates the size of the children of the AbstractPopup.
         * Cannot use getBounds() because it is not updated when children
         * are removed.
         *
         * @private
         * @override
         * @returns {object} An JavaScript object width the keys width and height.
         */
        getInnerSize() {
            let size = super.getInnerSize();

            if (this._closeButton) {
                size.width += this.smallPadding + this._closeButton.width;
            }

            if (this._buttons) {
                size.width = Math.max(
                    size.width,
                    this._buttons.x + this._buttons.width
                );
                size.height += this.innerPadding + this._buttons.height;
            }

            return size
        }
    }

    /**
     * Class that represents a PixiJS Popup.
     *
     * @example
     * // Create the popup
     * const popup = new Popup({
     *     header: 'Goethe',
     *     content: 'Man kann die Erfahrung nicht früh genug machen, wie entbehrlich man in der Welt ist.'
     * })
     *
     * // Add the popup to a DisplayObject
     * app.scene.addChild(popup)
     *
     * @class
     * @extends InteractivePopup
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/popup.html|DocTest}
     */
    class Popup extends InteractivePopup {
        /**
         * Creates an instance of a Popup.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the popup.
         * @param {boolean} [opts.closeButton=false] - Should a close button be displayed on the upper right corner?
         * @param {number} [opts.minWidth=0] - The minimum width of the popup.
         * @param {number} [opts.minHeight=0] - The minimum height of the popup.
         */
        constructor(opts = {}) {
            opts = Object.assign(
                {},
                {
                    closeButton: false,
                    minWidth: 0,
                    minHeight: 0
                },
                opts
            );

            super(opts);
        }
    }

    /**
     * Class that represents a PixiJS Modal.
     *
     * @example
     * // Create the button and the modal when clicked
     * const button = new Button({
     *     label: 'Show Modal',
     *     action: e => {
     *         const modal = new Modal({
     *             app: app,
     *             header: 'This is the header',
     *             content: 'This is the text.'
     *         })
     *         app.scene.addChild(modal)
     *     }
     * })
     *
     * // Add the button to a DisplayObject
     * app.scene.addChild(button)
     *
     * @class
     * @extends PIXI.Container
     * @extends InteractivePopup
     * @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/modal.html|DocTest}
     */
    class Modal extends PIXI.Container {
        /**
         * Creates an instance of a Modal.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the modal.
         * @param {number} [opts.id=auto generated] - The id of the modal.
         * @param {PIXIApp} [opts.app=window.app] - The app where the modal belongs to.
         * @param {number} [opts.backgroundFill=Theme.background] - The color of the background.
         * @param {number} [opts.backgroundFillAlpha=0.6] - The opacity of the background.
         * @param {boolean} [opts.closeOnBackground=true] - Should the modal be closed when the user clicks the
         *     background?
         * @param {boolean} [opts.visible=true] - Is the modal initially visible (property visible)?
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    app: window.app,
                    backgroundFill: theme.background,
                    backgroundFillAlpha: 0.6,
                    closeOnBackground: true,
                    visible: true
                },
                opts
            );

            this.id = this.opts.id;

            this.background = null;
            this.popup = null;

            this.alpha = 0;
            this.visible = this.opts.visible;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Modal} A reference to the modal for chaining.
         */
        setup() {
            // interaction
            //-----------------
            this.interactive = true;
            this.on('added', e => {
                if (this.visible) {
                    this.show();
                }
            });

            // background
            //-----------------
            let background = new PIXI.Graphics();
            this.background = background;
            this.addChild(this.background);

            if (this.opts.closeOnBackground) {
                background.interactive = true;
                background.on('pointerup', e => {
                    this.hide();
                });
            }

            // popup
            //-----------------
            const popupOpts = Object.assign({}, this.opts, {
                visible: true,
                onHidden: () => {
                    this.hide();
                }
            });
            let popup = new InteractivePopup(popupOpts);
            this.popup = popup;
            this.addChild(popup);
            popup.show();

            return this
        }

        /**
         * Should be called to refresh the layout of the modal. Can be used after resizing.
         *
         * @return {Modal} A reference to the modal for chaining.
         */
        layout() {
            const width = this.opts.app.size.width;
            const height = this.opts.app.size.height;

            // background
            //-----------------
            this.background.clear();
            this.background.beginFill(
                this.opts.backgroundFill,
                this.opts.backgroundFillAlpha
            );
            this.background.drawRect(0, 0, width, height);
            this.background.endFill();

            // position
            this.popup.x = width / 2 - this.popup.width / 2;
            this.popup.y = height / 2 - this.popup.height / 2;

            return this
        }

        /**
         * Shows the modal (sets his alpha values to 1).
         *
         * @return {Modal} A reference to the modal for chaining.
         */
        show() {
            TweenLite.to(this, this.theme.fast, {
                alpha: 1,
                onStart: () => (this.visible = true)
            });

            return this
        }

        /**
         * Hides the modal (sets his alpha values to 0).
         *
         * @return {Modal} A reference to the modal for chaining.
         */
        hide() {
            TweenLite.to(this, this.theme.fast, {
                alpha: 0,
                onComplete: () => (this.visible = false)
            });

            return this
        }

        /**
         * Sets or gets the header. The getter always returns a PIXI.Text object. The setter can receive
         * a string or a PIXI.Text object.
         *
         * @member {string|PIXI.Text}
         */
        get header() {
            return this.popup.header
        }
        set header(value) {
            this.opts.header = value;
            this.background.destroy();
            this.popup.destroy();
            this.setup().layout();
        }

        /**
         * Sets or gets the content. The getter always returns an PIXI.DisplayObject. The setter can receive
         * a string or a PIXI.DisplayObject.
         *
         * @member {string|PIXI.DisplayObject}
         */
        get content() {
            return this.popup.content
        }
        set content(value) {
            this.opts.content = value;
            this.background.destroy();
            this.popup.destroy();
            this.setup().layout();
        }
    }

    /**
     * Class that represents a Message. A message pops up and disappears after a specific amount of time.
     *
     * @example
     * // Create the PixiJS App
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 900,
     *     height: 250
     * }).setup().run()
     *
     * // Create a button
     * let button = new Button({
     *     label: 'Click me',
     *     action: e => {
     *         const message = new Message({
     *             app: app,
     *             header: 'Header',
     *             content: 'Text.'
     *         })
     *         app.scene.addChild(message)
     *     }
     * })
     *
     * // Add the button to the scene
     * app.scene.addChild(button)
     *
     * @class
     * @extends InteractivePopup
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/message.html|DocTest}
     */
    class Message extends InteractivePopup {
        /**
         * Creates an instance of a Message.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the message.
         * @param {PIXIApp} [opts.app=window.app] - The PIXIApp where this message belongs to.
         * @param {boolean} [opts.closeButton=false] - Should a close button be displayed in the upper right corner?
         * @param {number} [opts.minWidth=280] - The minimum width of the message box. Automatically expands with the content.
         * @param {number} [opts.minHeight=100] - The minimum height of the message box. Automatically expands with the content.
         * @param {number} [opts.margin=Theme.margin] - The outer spacing of the message box.
         * @param {string} [opts.align=right] - The horizontal position of the message box relative to the app. Possible
         *     values are left, center, right.
         * @param {string} [opts.verticalAlign=top] - The vertical position of the message box relative to the app. Possible
         *     values are top, middle, bottom.
         * @param {number} [opts.duration=5] - The duration in seconds when the message box should disappear.
         * @param {boolean} [opts.autoClose=true] - Should the message box be closed automatically?
         * @param {number} [opts.closeDuration=Theme.fast] - The duration in seconds of the closing of the message box.
         */
        constructor(opts = {}) {
            const theme = Theme.fromString(opts.theme);

            opts = Object.assign(
                {},
                {
                    app: window.app,
                    closeButton: false,
                    minWidth: 280,
                    minHeight: 100,
                    margin: theme.margin,
                    align: 'right', // left, center, right
                    verticalAlign: 'top', // top, middle, bottom
                    duration: 5,
                    autoClose: true,
                    closeDuration: theme.fast
                },
                opts
            );

            super(opts);
        }

        /**
         * Relayouts the position of the message box.
         *
         * @return {Message} Returns the message box for chaining.
         */
        layout() {
            super.layout();

            // horizontal
            switch (this.opts.align) {
                case 'left':
                    this.x = this.opts.margin;
                    break
                case 'center':
                    this.x = this.opts.app.size.width / 2 - this.width / 2;
                    break
                case 'right':
                    this.x =
                        this.opts.app.size.width - this.opts.margin - this.width;
                    break
            }

            // vertical
            switch (this.opts.verticalAlign) {
                case 'top':
                    this.y = this.opts.margin;
                    break
                case 'middle':
                    this.y = this.opts.app.size.height / 2 - this.height / 2;
                    break
                case 'bottom':
                    this.y =
                        this.opts.app.size.height - this.opts.margin - this.height;
                    break
            }
        }

        /**
         * Shows the message box.
         *
         * @private
         */
        show() {
            super.show();

            if (this.opts.autoClose) {
                window.setTimeout(() => {
                    this.hide();
                }, this.opts.duration * 1000);
            }
        }
    }

    /* globals WebKitPoint */

    /** Tests whether an object is empty
     * @param {Object} obj - the object to be tested
     * @return {boolean}
     */
    function isEmpty(obj) {
        // > isEmpty({})
        // true
        for (let i in obj) {
            return false
        }
        return true
    }

    function lerp(start, stop, amt) {
        return amt * (stop - start) + start
    }

    // Returns a function, that, as long as it continues to be invoked, will not
    // be triggered. The function will be called after it stops being called for
    // N milliseconds. If `immediate` is passed, trigger the function on the
    // leading edge, instead of the trailing.
    // Taken from: https://davidwalsh.name/essential-javascript-functions
    function debounce(func, wait, immediate) {
        let timeout;
        return function() {
            let context = this,
                args = arguments;
            let later = function() {
                timeout = null;
                if (!immediate) func.apply(context, args);
            };
            let callNow = immediate && !timeout;
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
            if (callNow) func.apply(context, args);
        }
    }

    /** Returns an id that is guaranteed to be unique within the livetime of the
     * application
     * @return {string}
     */
    let _idGenerator = 0;
    function getId() {
        return 'id' + _idGenerator++
    }

    class Dates {
        static create(fullYear, month, day) {
            return new Date(Date.UTC(fullYear, month, day))
        }

        static daysInMonth(date) {
            return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
        }

        static startYearRange(date) {
            return new Date(
                Date.UTC(date.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
            )
        }

        static endYearRange(date) {
            return new Date(Date.UTC(date.getFullYear() + 1, 0, 1))
        }

        static prevYear(date, offset = 1) {
            return this.create(date.getFullYear() - offset, 0, 1)
        }

        static nextYear(date, offset = 1) {
            return this.create(date.getFullYear() + offset, 0, 1)
        }

        static nextMonth(date) {
            return this.create(date.getFullYear(), date.getMonth() + 1, 1)
        }

        static nextDay(date) {
            return this.create(
                date.getFullYear(),
                date.getMonth(),
                date.getDate() + 1
            )
        }

        static nextHour(date) {
            // See http://stackoverflow.com/questions/1050720/adding-hours-to-javascript-date-object
            return new Date(date.getTime() + 60 * 60 * 1000)
        }

        static nextMinute(date) {
            // See above
            return new Date(date.getTime() + 60 * 1000)
        }

        static nextSecond(date) {
            // See above
            return new Date(date.getTime() + 1000)
        }

        static nextMillisecond(date) {
            // See above
            return new Date(date.getTime() + 1)
        }

        static *iterYears(start, end) {
            let date = this.create(start.getFullYear(), 0, 1);
            while (date <= end) {
                yield date;
                date = this.nextYear(date);
            }
            yield date;
        }

        static *iterMonths(year, limit = 12) {
            let month = 0;
            while (month < limit) {
                let date = this.create(year.getFullYear(), month, 1);
                yield date;
                month += 1;
            }
        }

        static *iterMonthsOfYears(years) {
            for (let year of years) {
                for (let month of this.iterMonths(year)) {
                    yield month;
                }
            }
        }

        static *iterDays(month) {
            let day = 1;
            let limit = Dates.daysInMonth(month);
            while (day <= limit) {
                let date = this.create(month.getFullYear(), month.getMonth(), day);
                yield date;
                day += 1;
            }
        }

        static *iterDaysOfMonths(months) {
            for (let month of months) {
                for (let day of this.iterDays(month)) {
                    yield day;
                }
            }
        }
    }
    /* Color conversion functions */

    class Colors {
        // http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb

        static rgb2num(red, green, blue) {
            let rgb = blue | (green << 8) | (red << 16);
            return 0x000000 + rgb
        }

        static rgb2hex(red, green, blue) {
            let rgb = blue | (green << 8) | (red << 16);
            return '#' + (0x1000000 + rgb).toString(16).slice(1)
        }

        static hex2rgb(hex) {
            // long version
            let r = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
            if (r) {
                return r.slice(1, 4).map(x => {
                    return parseInt(x, 16)
                })
            }
            // short version
            r = hex.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
            if (r) {
                return r.slice(1, 4).map(x => {
                    return 0x11 * parseInt(x, 16)
                })
            }
            return null
        }

        static rgb(r, g, b) {
            return { r, g, b }
        }

        static string2hex(str) {
            return parseInt('0x' + str.slice(1))
        }

        static lerp(rgb1, rgb2, amount) {
            return {
                r: Math.round(lerp(rgb1.r, rgb2.r, amount)),
                g: Math.round(lerp(rgb1.g, rgb2.g, amount)),
                b: Math.round(lerp(rgb1.b, rgb2.b, amount))
            }
        }

        static get violet() {
            return Colors.rgb2num(89, 34, 131)
        }

        static get steelblue() {
            return Colors.rgb2num(0, 130, 164)
        }

        static get ochre() {
            return Colors.rgb2num(181, 157, 0)
        }

        static get turquoise() {
            return Colors.rgb2num(34, 164, 131)
        }

        static get eminence() {
            return Colors.rgb2num(150, 60, 134)
        }

        static random() {
            let r = Math.round(Math.random() * 255);
            let g = Math.round(Math.random() * 255);
            let b = Math.round(Math.random() * 255);
            return Colors.rgb2num(r, g, b)
        }
    }

    class Cycle extends Array {
        constructor(...items) {
            super();
            for (let item of items) {
                this.push(item);
            }
            this.index = 0;
        }

        next() {
            if (this.index == this.length) {
                this.index = 0;
            }
            return this[this.index++]
        }

        current() {
            if (this.index === this.length) {
                this.index = 0;
            }
            return this[this.index]
        }
    }

    /** Static methods to compute 2D points with x and y coordinates.
     */
    class Points {
        static length(a) {
            return Math.sqrt(a.x * a.x + a.y * a.y)
        }

        static normalize(p) {
            let len = this.length(p);
            return this.multiplyScalar(p, 1 / len)
        }

        static mean(a, b) {
            return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
        }

        static subtract(a, b) {
            return { x: a.x - b.x, y: a.y - b.y }
        }

        static multiply(a, b) {
            return { x: a.x * b.x, y: a.y * b.y }
        }

        static divide(a, b) {
            return { x: a.x / b.x, y: a.y / b.y }
        }

        static multiplyScalar(a, b) {
            return { x: a.x * b, y: a.y * b }
        }

        static add(a, b) {
            return { x: a.x + b.x, y: a.y + b.y }
        }

        static negate(p) {
            return { x: -p.x, y: -p.y }
        }

        static angle(p1, p2) {
            return Math.atan2(p1.y - p2.y, p1.x - p2.x)
        }

        static normalizedAngle(p1, p2) {
            return Angle.normalize(this.angle(p1, p2))
        }

        static normalized2Angle(p1, p2) {
            return Angle.normalize2(this.angle(p1, p2))
        }

        static arc(p, alpha, radius) {
            return {
                x: p.x + radius * Math.cos(alpha),
                y: p.y + radius * Math.sin(alpha)
            }
        }

        static distance(a, b) {
            let dx = a.x - b.x;
            let dy = a.y - b.y;
            return Math.sqrt(dx * dx + dy * dy)
        }

        // Distance == 0.0 indicates an inside relation.
        static distanceToRect(p, r) {
            let cx = Math.max(Math.min(p.x, r.x + r.width), r.x);
            let cy = Math.max(Math.min(p.y, r.y + r.height), r.y);
            return Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy))
        }

        static fromPageToNode(element, p) {
            //    if (window.webkitConvertPointFromPageToNode) {
            //             return window.webkitConvertPointFromPageToNode(element,
            //                                                     new WebKitPoint(p.x, p.y))
            //         }
            return window.convertPointFromPageToNode(element, p.x, p.y)
        }

        static fromNodeToPage(element, p) {
            //  if (window.webkitConvertPointFromNodeToPage) {
            //             return window.webkitConvertPointFromNodeToPage(element,
            //                                                     new WebKitPoint(p.x, p.y))
            //         }
            return window.convertPointFromNodeToPage(element, p.x, p.y)
        }
    }

    /** Static methods to compute angles.
     */
    class Angle {
        static normalize(angle) {
            let TAU = Math.PI * 2.0;
            while (angle > Math.PI) {
                angle -= TAU;
            }
            while (angle < -Math.PI) {
                angle += TAU;
            }
            return angle
        }

        static normalize2(angle) {
            let TAU = Math.PI * 2.0;
            while (angle > TAU) {
                angle -= TAU;
            }
            while (angle < 0) {
                angle += TAU;
            }
            return angle
        }

        static normalizeDegree(angle) {
            let full = 360.0;
            while (angle > 180.0) {
                angle -= full;
            }
            while (angle < -180.0) {
                angle += full;
            }
            return angle
        }

        static normalizedDiff(a, b) {
            return this.normalize(this.diff(a, b))
        }

        static normalized2Diff(a, b) {
            return this.normalize2(this.diff(a, b))
        }

        static diff(a, b) {
            return Math.atan2(Math.sin(a - b), Math.cos(a - b))
        }

        static degree2radian(degree) {
            return (Math.PI * degree) / 180.0
        }

        static radian2degree(rad) {
            return (180.0 / Math.PI) * rad
        }
    }

    class Elements$1 {
        static setStyle(element, styles) {
            for (let key in styles) {
                element.style[key] = styles[key];
            }
        }

        static addClass(element, cssClass) {
            element.classList.add(cssClass);
        }

        static removeClass(element, cssClass) {
            element.classList.remove(cssClass);
        }

        static toggleClass(element, cssClass) {
            element.classList.toggle(cssClass);
        }

        static hasClass(element, cssClass) {
            return element.classList.contains(cssClass)
        }
    }

    class MapProxy {
        /* This class is needed if we want to use the interaction classes
        in Firefox 45.8 and modern Browsers.

        A workaround for https://github.com/babel/babel/issues/2334
      */
        constructor() {
            this.map = new Map();
        }

        get size() {
            return this.map.size
        }

        get(key) {
            return this.map.get(key)
        }

        set(key, value) {
            return this.map.set(key, value)
        }

        delete(key) {
            return this.map.delete(key)
        }

        clear() {
            return this.map.clear()
        }

        has(key) {
            return this.map.has(key)
        }

        keys() {
            return this.map.keys()
        }

        values() {
            return this.map.values()
        }

        entries() {
            return this.map.entries()
        }

        forEach(func) {
            this.map.forEach(func);
        }
    }

    /* Based om https://gist.github.com/cwleonard/e124d63238bda7a3cbfa */
    class Polygon {
        /*
         *  This is the Polygon constructor. All points are center-relative.
         */
        constructor(center) {
            this.points = new Array();
            this.center = center;
        }

        /*
         *  Point x and y values should be relative to the center.
         */
        addPoint(p) {
            this.points.push(p);
        }

        /*
         *  Point x and y values should be absolute coordinates.
         */
        addAbsolutePoint(p) {
            this.points.push({ x: p.x - this.center.x, y: p.y - this.center.y });
        }

        /*
         * Returns the number of sides. Equal to the number of vertices.
         */
        getNumberOfSides() {
            return this.points.length
        }

        /*
         * rotate the polygon by a number of radians
         */
        rotate(rads) {
            for (let i = 0; i < this.points.length; i++) {
                let x = this.points[i].x;
                let y = this.points[i].y;
                this.points[i].x = Math.cos(rads) * x - Math.sin(rads) * y;
                this.points[i].y = Math.sin(rads) * x + Math.cos(rads) * y;
            }
        }

        /*
         *  The draw function takes as a parameter a Context object from
         *  a Canvas element and draws the polygon on it.
         */
        draw(context, { lineWidth = 2, stroke = '#000000', fill = null } = {}) {
            context.beginPath();
            context.moveTo(
                this.points[0].x + this.center.x,
                this.points[0].y + this.center.y
            );
            for (let i = 1; i < this.points.length; i++) {
                context.lineTo(
                    this.points[i].x + this.center.x,
                    this.points[i].y + this.center.y
                );
            }
            context.closePath();
            context.lineWidth = lineWidth;
            if (stroke) {
                context.strokeStyle = stroke;
                context.stroke();
            }
            if (fill) {
                context.fillStyle = fill;
                context.fill();
            }
        }

        absolutePoints() {
            let result = new Array();
            for (let p of this.points) {
                result.push(Points.add(p, this.center));
            }
            return result
        }

        flatAbsolutePoints() {
            let result = new Array();
            for (let p of this.points) {
                let a = Points.add(p, this.center);
                result.push(a.x);
                result.push(a.y);
            }
            return result
        }

        /*
         *  This function returns true if the given point is inside the polygon,
         *  and false otherwise.
         */
        containsPoint(pnt) {
            let nvert = this.points.length;
            let testx = pnt.x;
            let testy = pnt.y;

            let vertx = new Array();
            for (let q = 0; q < this.points.length; q++) {
                vertx.push(this.points[q].x + this.center.x);
            }

            let verty = new Array();
            for (let w = 0; w < this.points.length; w++) {
                verty.push(this.points[w].y + this.center.y);
            }

            let i,
                j = 0;
            let c = false;
            for (i = 0, j = nvert - 1; i < nvert; j = i++) {
                if (
                    verty[i] > testy != verty[j] > testy &&
                    testx <
                        ((vertx[j] - vertx[i]) * (testy - verty[i])) /
                            (verty[j] - verty[i]) +
                            vertx[i]
                )
                    c = !c;
            }
            return c
        }

        multiplyScalar(scale) {
            let center = Points.multiplyScalar(this.center, scale);
            let clone = new Polygon(center);
            for (let p of this.points) {
                clone.addPoint(Points.multiplyScalar(p, scale));
            }
            return clone
        }

        /*
         *  To detect intersection with another Polygon object, this
         *  function uses the Separating Axis Theorem. It returns false
         *  if there is no intersection, or an object if there is. The object
         *  contains 2 fields, overlap and axis. Moving the polygon by overlap
         *  on axis will get the polygons out of intersection.
         */
        intersectsWith(other) {
            let axis = { x: 0, y: 0 };
            let tmp, minA, maxA, minB, maxB;
            let side, i;
            let smallest = null;
            let overlap = 99999999;

            /* test polygon A's sides */
            for (side = 0; side < this.getNumberOfSides(); side++) {
                /* get the axis that we will project onto */
                if (side == 0) {
                    axis.x =
                        this.points[this.getNumberOfSides() - 1].y -
                        this.points[0].y;
                    axis.y =
                        this.points[0].x -
                        this.points[this.getNumberOfSides() - 1].x;
                } else {
                    axis.x = this.points[side - 1].y - this.points[side].y;
                    axis.y = this.points[side].x - this.points[side - 1].x;
                }

                /* normalize the axis */
                tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
                axis.x /= tmp;
                axis.y /= tmp;

                /* project polygon A onto axis to determine the min/max */
                minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
                for (i = 1; i < this.getNumberOfSides(); i++) {
                    tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
                    if (tmp > maxA) maxA = tmp;
                    else if (tmp < minA) minA = tmp;
                }
                /* correct for offset */
                tmp = this.center.x * axis.x + this.center.y * axis.y;
                minA += tmp;
                maxA += tmp;

                /* project polygon B onto axis to determine the min/max */
                minB = maxB =
                    other.points[0].x * axis.x + other.points[0].y * axis.y;
                for (i = 1; i < other.getNumberOfSides(); i++) {
                    tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
                    if (tmp > maxB) maxB = tmp;
                    else if (tmp < minB) minB = tmp;
                }
                /* correct for offset */
                tmp = other.center.x * axis.x + other.center.y * axis.y;
                minB += tmp;
                maxB += tmp;

                /* test if lines intersect, if not, return false */
                if (maxA < minB || minA > maxB) {
                    return false
                } else {
                    let o = maxA > maxB ? maxB - minA : maxA - minB;
                    if (o < overlap) {
                        overlap = o;
                        smallest = { x: axis.x, y: axis.y };
                    }
                }
            }

            /* test polygon B's sides */
            for (side = 0; side < other.getNumberOfSides(); side++) {
                /* get the axis that we will project onto */
                if (side == 0) {
                    axis.x =
                        other.points[other.getNumberOfSides() - 1].y -
                        other.points[0].y;
                    axis.y =
                        other.points[0].x -
                        other.points[other.getNumberOfSides() - 1].x;
                } else {
                    axis.x = other.points[side - 1].y - other.points[side].y;
                    axis.y = other.points[side].x - other.points[side - 1].x;
                }

                /* normalize the axis */
                tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
                axis.x /= tmp;
                axis.y /= tmp;

                /* project polygon A onto axis to determine the min/max */
                minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
                for (i = 1; i < this.getNumberOfSides(); i++) {
                    tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
                    if (tmp > maxA) maxA = tmp;
                    else if (tmp < minA) minA = tmp;
                }
                /* correct for offset */
                tmp = this.center.x * axis.x + this.center.y * axis.y;
                minA += tmp;
                maxA += tmp;

                /* project polygon B onto axis to determine the min/max */
                minB = maxB =
                    other.points[0].x * axis.x + other.points[0].y * axis.y;
                for (i = 1; i < other.getNumberOfSides(); i++) {
                    tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
                    if (tmp > maxB) maxB = tmp;
                    else if (tmp < minB) minB = tmp;
                }
                /* correct for offset */
                tmp = other.center.x * axis.x + other.center.y * axis.y;
                minB += tmp;
                maxB += tmp;

                /* test if lines intersect, if not, return false */
                if (maxA < minB || minA > maxB) {
                    return false
                } else {
                    let o = maxA > maxB ? maxB - minA : maxA - minB;
                    if (o < overlap) {
                        overlap = o;
                        smallest = { x: axis.x, y: axis.y };
                    }
                }
            }
            return { overlap: overlap + 0.001, axis: smallest }
        }

        static fromPoints(points) {
            let min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE };
            let max = { x: Number.MIN_VALUE, y: Number.MIN_VALUE };
            for (let p of points) {
                min.x = Math.min(p.x, min.x);
                max.x = Math.max(p.x, max.x);
                min.y = Math.min(p.y, min.y);
                max.y = Math.max(p.y, max.y);
            }
            let center = Points.mean(min, max);
            let polygon = new Polygon(center);
            for (let p of points) {
                polygon.addAbsolutePoint(p);
            }
            return polygon
        }
    }

    class LowPassFilter {
        constructor(smoothing = 0.5, bufferMaxSize = 10) {
            this.smoothing = smoothing; // must be smaller than 1
            this.buffer = []; // FIFO queue
            this.bufferMaxSize = bufferMaxSize;
        }

        /**
         * Setup buffer with array of values
         *
         * @param {array} values
         * @returns {array}
         * @access public
         */
        setup(values) {
            for (let i = 0; i < values.length; i++) {
                this.__push(values[i]);
            }
            return this.buffer
        }

        /**
         * Clear buffer to prepare for new values.
         *
         * @access public
         */
        clear() {
            this.buffer = [];
        }

        /**
         * Add new value to buffer (FIFO queue)
         *
         * @param {integer|float} value
         * @returns {integer|float}
         * @access private
         */
        __push(value) {
            let removed =
                this.buffer.length === this.bufferMaxSize ? this.buffer.shift() : 0;

            this.buffer.push(value);
            return removed
        }

        /**
         * Smooth value from stream
         *
         * @param {integer|float} nextValue
         * @returns {integer|float}
         * @access public
         */
        next(nextValue) {
            // push new value to the end, and remove oldest one
            let removed = this.__push(nextValue);
            // smooth value using all values from buffer
            let result = this.buffer.reduce((last, current) => {
                return this.smoothing * current + (1 - this.smoothing) * last
            }, removed);
            // replace smoothed value
            this.buffer[this.buffer.length - 1] = result;
            return result
        }

        /**
         * Smooth array of values
         *
         * @param {array} values
         * @returns {undefined}
         * @access public
         */
        smoothArray(values) {
            let value = values[0];
            for (let i = 1; i < values.length; i++) {
                let currentValue = values[i];
                value += (currentValue - value) * this.smoothing;
                values[i] = Math.round(value);
            }
            return values
        }
    }

    /* global apollo, subscriptions, gql */

    /**
     * A special InteractionManager for fullscreen apps, which may
     * go beyond the limits of WebGL drawing buffers. On Safari and Chrome
     * the drawing buffers are limited to 4096 in width (Safari) or 4096x4096
     * in total buffer size (Chrome). The original InteractionManager.mapPositionToPoint
     * does not work with these extreme sizes which mainly occur if large
     * retina displays (>= 4K) are used with devicePixelRatio > 1.
     *
     * @private
     * @class
     * @extends PIXI.interaction.InteractionManager
     * @see {@link http://pixijs.download/dev/docs/PIXI.interaction.InteractionManager.html|PIXI.interaction.InteractionManager}
     * @see {@link https://stackoverflow.com/questions/29710696/webgl-drawing-buffer-size-does-not-equal-canvas-size}
     */
    class FullscreenInteractionManager extends PIXI.interaction.InteractionManager {
        mapPositionToPoint(point, x, y) {
            let resolution = this.renderer.resolution;
            let extendWidth = 1.0;
            let extendHeight = 1.0;
            let dy = 0;
            let canvas = this.renderer.view;
            let context = canvas.getContext('webgl');
            if (
                context.drawingBufferWidth < canvas.width ||
                context.drawingBufferHeight < canvas.height
            ) {
                extendWidth = context.drawingBufferWidth / canvas.width;
                extendHeight = context.drawingBufferHeight / canvas.height;
                //dx = wantedWidth - context.drawingBufferWidth
                dy = (canvas.height - context.drawingBufferHeight) / resolution;
            }
            x *= extendWidth;
            y *= extendHeight;

            super.mapPositionToPoint(point, x, y + dy);
        }
    }

    /**
     * The class PixiApp extends the class PIXI.Application
     * by several functions and makes meaningful pre-assumptions.
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 450,
     *     height: 150,
     *     fpsLogging: true,
     *     theme: 'light',
     *     transparent: false
     * }).setup().run()
     *
     * @class
     * @extends PIXI.Application
     * @see {@link http://pixijs.download/dev/docs/PIXI.Application.html|PIXI.Application}
     */
    class PIXIApp extends PIXI.Application {
        /**
         * Creates an instance of a PixiApp.
         *
         * @constructor
         * @param {object} [opts={}] - An options object. The following options can be set:
         * @param {number} [opts.width] - The width of the renderer. If no set, the application will run in fullscreen.
         * @param {number} [opts.height] - The height of the renderer. If no set, the application will run in fullscreen.
         * @param {HTMLElement} [opts.view] - The canvas HTML element. If not set, a render-element is added inside the body.
         * @param {boolean} [opts.transparent=true] - Should the render view be transparent?
         * @param {boolean} [opts.antialias=true] - Sets antialias (only applicable in chrome at the moment).
         * @param {number} [opts.resolution=window.devicePixelRatio | 1] - The resolution / device pixel ratio of the renderer, retina would be 2.
         * @param {boolean} [opts.autoResize=true] - Should the canvas-element be resized automatically if the resolution was set?
         * @param {number} [opts.backgroundColor=0x282828] - The color of the background.
         * @param {string|Theme} [opts.theme=dark] - The name of the theme (dark, light, red) or a Theme object to use for styling.
         * @param {boolean} [opts.fpsLogging=false] - If set to true, the current frames per second are displayed in the upper left corner.
         * @param {object} [opts.progress={}] - Can be used to add options to the progress bar. See class Progress for more informations.
         * @param {boolean} [opts.forceCanvas=false] - Prevents selection of WebGL renderer, even if such is present.
         * @param {boolean} [opts.roundPixels=true] - Align PIXI.DisplayObject coordinates to screen resolution.
         * @param {boolean} [opts.monkeyPatchMapping=true] - Monkey patch for canvas fullscreen support on large displays.
         * @param {boolean} [opts.adaptive=true] - Adds Graphics adaptive calculation of quadratic curve and arc subdivision.
         */
        constructor({
            width = null,
            height = null,
            view = null,
            transparent = true,
            backgroundColor = 0x282828,
            theme = 'dark',
            antialias = true,
            resolution = window.devicePixelRatio || 1,
            autoResize = true,
            fpsLogging = false,
            progress = {},
            forceCanvas = false,
            roundPixels = true,
            monkeyPatchMapping = true,
            adaptive = true,
            graphql = false
        }) {
            const fullScreen = !width || !height;

            if (fullScreen) {
                width = window.innerWidth;
                height = window.innerHeight;
            }

            super({
                view,
                width,
                height,
                transparent,
                antialias,
                resolution,
                autoResize,
                backgroundColor,
                forceCanvas,
                roundPixels // not needed for PixiJS >= 5
            });

            this.width = width;
            this.height = height;
            this.theme = Theme.fromString(theme);
            this.fpsLogging = fpsLogging;
            this.progressOpts = progress;
            this.fullScreen = fullScreen;
            this.orient = null;
            this.originalMapPositionToPoint = null;
            this.monkeyPatchMapping = monkeyPatchMapping;
            if (parseInt(PIXI.VERSION) >= 5) {
                PIXI.settings.ROUND_PIXELS = roundPixels;
                PIXI.GRAPHICS_CURVES.adaptive = adaptive;
            } else {
                PIXI.Graphics.CURVES.adaptive = adaptive;
            }
            this.graphql = graphql;
            if (fullScreen || autoResize) {
                console.log('App is in fullScreen mode or autoResize mode');
                const resizeDebounced = debounce(event => this.resize(event), 50);
                window.addEventListener('resize', resizeDebounced);
                document.body.addEventListener(
                    'orientationchange',
                    this.checkOrientation.bind(this)
                );
            }
            if (monkeyPatchMapping) {
                console.log('Using monkey patched coordinate mapping');
                // Pluggin the specializtion does not work. Monkey patching does
                // this.renderer.plugins.interaction = new FullscreenInteractionManager(this.renderer)
                this.monkeyPatchPixiMapping();
            }
        }

        /**
         * Extra setup method to construct complex scenes, etc...
         * Overwrite this method if you need additonal views and components.
         *
         * @return {PIXIApp} A reference to the PIXIApp for chaining.
         */
        setup() {
            this.scene = this.sceneFactory();
            this.stage.addChild(this.scene);

            // fpsLogging
            if (this.fpsLogging) {
                this.addFpsDisplay();
            }

            // GraphQL
            if (this.graphql && typeof apollo !== 'undefined') {
                const networkInterface = apollo.createNetworkInterface({
                    uri: '/graphql'
                });

                const wsClient = new subscriptions.SubscriptionClient(
                    `wss://${location.hostname}/subscriptions`,
                    {
                        reconnect: true,
                        connectionParams: {}
                    }
                );

                const networkInterfaceWithSubscriptions = subscriptions.addGraphQLSubscriptions(
                    networkInterface,
                    wsClient
                );

                this.apolloClient = new apollo.ApolloClient({
                    networkInterface: networkInterfaceWithSubscriptions
                });
            }

            // progress
            this._progress = new Progress(
                Object.assign({ theme: this.theme }, this.progressOpts, {
                    app: this
                })
            );
            this._progress.visible = false;
            this.stage.addChild(this._progress);

            return this
        }

        /**
         * Tests whether the width is larger than the height of the application.
         *
         * @return {boolean} Returns true if app is in landscape mode.
         */
        orientation() {
            return this.width > this.height
        }

        /**
         * Checks orientation and adapts view size if necessary. Implements a
         * handler for the orientationchange event.
         *
         * @param {event=} - orientationchange event
         */
        checkOrientation(event) {
            var value = this.orientation();
            if (value != this.orient) {
                setTimeout(
                    100,
                    function() {
                        this.orientationChanged(true);
                    }.bind(this)
                );
                this.orient = value;
            }
        }

        /**
         * Called if checkOrientation detects an orientation change event.
         *
         * @param {boolean=} [force=false] - Called if checkOrientation detects an orientation change event.
         */
        orientationChanged(force = false) {
            if (this.expandRenderer() || force) {
                this.layout();
            }
        }

        /**
         * Called after a resize. Empty method but can be overwritten to
         * adapt their layout to the new app size.
         *
         * @param {number} [width] - The width of the app.
         * @param {number} [height] - The height of the app.
         */
        layout(width, height) {}

        /**
         * Draws the display tree of the app. Typically this can be delegated
         * to the layout method.
         *
         */
        draw() {
            this.layout(this.width, this.height);
        }

        /*
         * Run the application. Override this method with everything
         * that is needed to maintain your App, e.g. setup calls, main loops, etc.
         *
         */
        run() {
            return this
        }

        /*
         * Overwrite this factory method if your application needs a special
         * scene object.
         *
         * @returns {PIXI.Container} - A new PIXI Container for use as a scene.
         */
        sceneFactory() {
            return new PIXI.Container()
        }

        /**
         * Adds the display of the frames per second to the renderer in the upper left corner.
         *
         * @return {PIXIApp} - Returns the PIXIApp for chaining.
         */
        addFpsDisplay() {
            const fpsDisplay = new FpsDisplay(this);
            this.stage.addChild(fpsDisplay);

            return this
        }

        /**
         * Returns the size of the renderer as an object with the keys width and height.
         *
         * @readonly
         * @member {object}
         */
        get size() {
            return { width: this.width, height: this.height }
        }

        /**
         * Returns the center of the renderer as an object with the keys x and y.
         *
         * @readonly
         * @member {object}
         */
        get center() {
            return { x: this.width / 2, y: this.height / 2 }
        }

        /**
         * Resizes the renderer to fit into the window or given width and height.
         *
         * @param {object} [event] - The event.
         * @param {object=} [opts={}] - The event.
         * @param {number} [opts.width=window.innerWidth] - The width of the app to resize to.
         * @param {number} [opts.height=window.innerHeight] - The height of the app to resize to.
         * @return {PIXIApp} - Returns the PIXIApp for chaining.
         */
        resize(
            event,
            { width = window.innerWidth, height = window.innerHeight } = {}
        ) {
            this.width = width;
            this.height = height;
            this.expandRenderer();
            this.layout(width, height);
            //console.log("App.resize", width, height, window.innerWidth, window.innerHeight )
            // if (this.scene) {
            // console.log("gl.drawingBufferWidth", this.renderer.view.getContext('webgl').drawingBufferWidth)
            // console.log("scene", this.scene.scale, this.renderer, this.renderer.autoResize, this.renderer.resolution)
            // }
            return this
        }

        /**
         * @todo Write the documentation.
         *
         * @private
         */
        monkeyPatchPixiMapping() {
            if (this.originalMapPositionToPoint === null) {
                let interactionManager = this.renderer.plugins.interaction;
                this.originalMapPositionToPoint =
                    interactionManager.mapPositionToPoint;
                interactionManager.mapPositionToPoint = (point, x, y) => {
                    return this.fixedMapPositionToPoint(point, x, y)
                };
            }
        }

        /**
         * In some browsers the canvas is distorted if the screen resolution and
         * overall size of the canvas exceeds the internal limits (e.g. 4096 x 4096 pixels).
         * To compensate these distortions we need to fix the mapping to the actual
         * drawing buffer coordinates.
         * @private
         * @param {any} local
         * @param {number} x
         * @param {number} y
         * @return {} interactionManager.mapPositionToPoint
         */
        fixedMapPositionToPoint(local, x, y) {
            let resolution = this.renderer.resolution;
            let interactionManager = this.renderer.plugins.interaction;
            let extendWidth = 1.0;
            let extendHeight = 1.0;
            let dy = 0;
            let canvas = this.renderer.view;
            let context = canvas.getContext('webgl');

            if (
                context !== null &&
                (context.drawingBufferWidth < canvas.width ||
                    context.drawingBufferHeight < canvas.height)
            ) {
                extendWidth = context.drawingBufferWidth / canvas.width;
                extendHeight = context.drawingBufferHeight / canvas.height;
                //dx = wantedWidth - context.drawingBufferWidth
                dy = (canvas.height - context.drawingBufferHeight) / resolution;
            }
            x *= extendWidth;
            y *= extendHeight;
            return this.originalMapPositionToPoint.call(
                interactionManager,
                local,
                x,
                y + dy
            )
        }

        /**
         * Expand the renderer step-wise on resize.
         *
         * @param {number} [expand] - The amount of additional space for the renderer [px].
         * @return {boolean} true if the renderer was resized.
         */
        expandRenderer(expand = 256) {
            let renderer = this.renderer;
            let resolution = this.renderer.resolution;
            let ww = this.width;
            let hh = this.height;
            let sw = this.screen.width;
            let sh = this.screen.height;
            if (ww > sw || hh > sh) {
                //console.log('App.expandRenderer')
                renderer.resize(ww + expand, hh + expand);
                return true
            }

            renderer.resize(ww, hh);
            return false
        }

        /**
         * Set the loading progress of the application. If called for the first time, display the progress bar.
         *
         * @param {number} [value] - Should be a value between 0 and 100. If 100, the progress bar will disappear.
         * @return {PIXIApp|Progress} The PixiApp object for chaining or the Progress object when the method was
         *     called without a parameter.
         */
        progress(value) {
            if (typeof value === 'undefined') {
                return this._progress
            }

            this._progress.visible = true;
            this._progress.progress = value;

            return this
        }

        /**
         * Opens a new Modal object binded to this app.
         *
         * @param {object} [opts] - An options object for the Modal object.
         * @return {Modal} Returns the Modal object.
         */
        modal(opts = {}) {
            let modal = new Modal(
                Object.assign({ theme: this.theme }, opts, { app: this })
            );
            this.scene.addChild(modal);

            return modal
        }

        /**
         * Opens a new Message object binded to this app.
         *
         * @param {object} [opts] - An options object for the Message object.
         * @return {Message} Returns the Message object.
         */
        message(opts = {}) {
            let message = new Message(
                Object.assign({ theme: this.theme }, opts, { app: this })
            );
            this.scene.addChild(message);

            return message
        }

        /**
         * Loads sprites, e.g. images into the PIXI TextureCache.
         *
         * @param {string|string[]} resources - A String or an Array of urls to the images to load.
         * @param {function} [loaded] - A callback which is executed after all resources has been loaded.
         *     Receives one paramter, a Map of sprites where the key is the path of the image which was
         *     loaded and the value is the PIXI.Sprite object.
         * @param {object} [opts] - An options object for more specific parameters.
         * @param {boolean} [opts.resolutionDependent=true] - Should the sprites be loaded dependent of the
         *     renderer resolution?
         * @param {boolean} [opts.progress=false] - Should a progress bar display the loading status?
         * @return {PIXIApp} The PIXIApp object for chaining.
         */
        loadSprites(
            resources,
            loaded = null,
            { resolutionDependent = true, progress = false } = {}
        ) {
            this.loadTextures(
                resources,
                textures => {
                    let sprites = new Map();

                    for (let [key, texture] of textures) {
                        sprites.set(key, new PIXI.Sprite(texture));
                    }

                    if (loaded) {
                        loaded.call(this, sprites);
                    }
                },
                { resolutionDependent, progress }
            );

            return this
        }

        /**
         * Loads textures, e.g. images into the PIXI TextureCache.
         *
         * @param {string|string[]} resources - A String or an Array of urls to the images to load.
         * @param {function} [loaded] - A callback which is executed after all resources has been loaded.
         *     Receives one paramter, a Map of textures where the key is the path of the image which was
         *     loaded and the value is the PIXI.Texture object.
         * @param {object} [opts] - An options object for more specific parameters.
         * @param {boolean} [opts.resolutionDependent=true] - Should the textures be loaded dependent of the
         *     renderer resolution?
         * @param {boolean} [opts.progress=false] - Should a progress bar display the loading status?
         * @return {PIXIApp} The PIXIApp object for chaining.
         */
        loadTextures(
            resources,
            loaded = null,
            { resolutionDependent = true, progress = false } = {}
        ) {
            if (!Array.isArray(resources)) {
                resources = [resources];
            }

            const loader = this.loader;

            for (let resource of resources) {
                if (!loader.resources[resource]) {
                    if (resolutionDependent) {
                        let resolution = Math.round(this.renderer.resolution);
                        switch (resolution) {
                            case 2:
                                loader.add(
                                    resource,
                                    resource.replace(/\.([^.]*)$/, '@2x.$1')
                                );
                                break
                            case 3:
                                loader.add(
                                    resource,
                                    resource.replace(/\.([^.]*)$/, '@3x.$1')
                                );
                                break
                            default:
                                loader.add(resource);
                                break
                        }
                    } else {
                        loader.add(resource);
                    }
                }
            }

            if (progress) {
                loader.on('progress', e => {
                    this.progress(e.progress);
                });
            }

            loader.load((loader, resources) => {
                const textures = new Map();

                for (let key in resources) {
                    textures.set(key, resources[key].texture);
                }

                if (loaded) {
                    loaded.call(this, textures);
                }
            });

            return this
        }

        /**
         * Queries the GraphQL endpoint.
         *
         * @param {string} [query] - The GraphQL query string.
         * @param {object} [opts={}] - An options object. The following options can be set:
         *     http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.query
         * @return {Promise} Returns a Promise which is either resolved with the resulting data or
         *     rejected with an error.
         */
        query(query, opts = {}) {
            if (typeof query === 'string') {
                opts = Object.assign({}, opts, { query });
            } else {
                opts = Object.assign({}, query);
            }

            opts.query = opts.query.trim();

            if (!opts.query.startsWith('query')) {
                if (opts.query.startsWith('{')) {
                    opts.query = `query ${opts.query}`;
                } else {
                    opts.query = `query {${opts.query}}`;
                }
            }

            opts.query = gql(opts.query);

            return this.apolloClient.query(opts)
        }

        /**
         * Mutate the GraphQL endpoint.
         *
         * @param {string} [mutation] - The GraphQL mutation string.
         * @param {object} [opts={}] - An options object. The following options can be set:
         *     http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.mutate
         * @return {Promise} Returns a Promise which is either resolved with the resulting data or
         *     rejected with an error.
         */
        mutate(mutation, opts = {}) {
            if (typeof mutation === 'string') {
                opts = Object.assign({}, opts, { mutation });
            } else {
                opts = Object.assign({}, mutation);
            }

            opts.mutation = opts.mutation.trim();

            if (!opts.mutation.startsWith('mutation')) {
                if (opts.mutation.startsWith('{')) {
                    opts.mutation = `mutation ${opts.mutation}`;
                } else {
                    opts.mutation = `mutation {${opts.mutation}}`;
                }
            }

            opts.mutation = gql(opts.mutation);

            return this.apolloClient.mutate(opts)
        }

        /**
         * Subscribe the GraphQL endpoint.
         *
         * @param {string} [subscription] - The GraphQL subscription.
         * @param {object} [opts={}] - An options object. The following options can be set:
         *     http://dev.apollodata.com/core/apollo-client-api.html#ApolloClient.query
         * @return {Promise} Returns a Promise which is either resolved with the resulting data or
         *     rejected with an error.
         */
        subscribe(subscription, opts = {}) {
            if (typeof subscription === 'string') {
                opts = Object.assign({}, opts, { subscription });
            } else {
                opts = Object.assign({}, subscription);
            }

            opts.subscription = opts.subscription.trim();

            if (!opts.subscription.startsWith('subscription')) {
                if (opts.subscription.startsWith('{')) {
                    opts.subscription = `subscription ${opts.subscription}`;
                } else {
                    opts.subscription = `subscription {${opts.subscription}}`;
                }
            }

            opts.query = gql(opts.subscription);

            delete opts.subscription;

            return this.apolloClient.subscribe(opts)
        }

        /**
         * Supports the page as a global coordinate system and converts browser page coordinates
         * to local DisplayObject coordinates.
         *
         * @param {DisplayObject} displayObject - The PIXI displayObject.
         * @param {number} x - The x coordinate.
         * @param {number} y - The y coordinate.
         *
         * @return {PIXI.Point} Returns a PIXI.Point.
         */

        convertPointFromPageToNode(displayObject, x, y) {
            let resolution = this.renderer.resolution;
            console.log('resolution', resolution);
            let pixiGlobal = window.convertPointFromPageToNode(app.view, x, y);
            pixiGlobal.x /= resolution;
            pixiGlobal.y /= resolution;
            return displayObject.toLocal(new PIXI.Point(pixiGlobal.x, pixiGlobal.y))
        }

        /**
         * Supports the page as a global coordinate system and converts local DisplayObject coordinates
         * to browser page coordinates.
         *
         * @param {DisplayObject} displayObject - The PIXI displayObject.
         * @param {number} x - The x coordinate.
         * @param {number} y - The y coordinate.
         *
         * @return {Point} Returns a DOM Point.
         */

        convertPointFromNodeToPage(displayObject, x, y) {
            let resolution = this.renderer.resolution;
            let pixiGlobal = displayObject.toGlobal(new PIXI.Point(x, y));
            pixiGlobal.x *= resolution;
            pixiGlobal.y *= resolution;
            // console.log("app.convertPointFromNodeToPage", pixiGlobal)
            return window.convertPointFromNodeToPage(
                app.view,
                pixiGlobal.x,
                pixiGlobal.y
            )
        }
    }

    /**
     * The class fpsdisplay shows in the upper left corner
     * of the renderer the current image refresh rate.
     *
     * @private
     * @class
     * @extends PIXI.Graphics
     * @see {@link http://pixijs.download/dev/docs/PIXI.Graphics.html|PIXI.Graphics}
     */
    class FpsDisplay extends PIXI.Graphics {
        /**
         * Creates an instance of a FpsDisplay.
         *
         * @constructor
         * @param {PIXIApp} app - The PIXIApp where the frames per second should be displayed.
         */
        constructor(app) {
            super();

            this.app = app;

            this.lineStyle(3, 0x434f4f, 1)
                .beginFill(0x434f4f, 0.6)
                .drawRoundedRect(0, 0, 68, 32, 5)
                .endFill()
                .position.set(20, 20);

            this.text = new PIXI.Text(
                this.fps,
                new PIXI.TextStyle({
                    fontFamily: 'Arial',
                    fontSize: 14,
                    fontWeight: 'bold',
                    fill: '#f6f6f6',
                    stroke: '#434f4f',
                    strokeThickness: 3
                })
            );
            this.text.position.set(6, 6);

            this.addChild(this.text);

            this.refreshFps();

            window.setInterval(this.refreshFps.bind(this), 1000);
        }

        /**
         * Refreshes fps numer.
         *
         * @return {PIXIApp} Returns the PIXIApp object for chaining.
         */
        refreshFps() {
            this.text.text = `${this.app.ticker.FPS.toFixed(1)} fps`;

            return this
        }
    }

    /**
     * A Gaussian blur filter. With this filter, you can blur an area of a PIXI.DisplayObject. This cannot
     * be done with the PIXI.filters.BlurFilter (when you use the PIXI.filters.BlurFilter with
     * an filter area, all pixels outside of the area are not displayed). Attention: The area of
     * the filter is always in global scope, NOT relative to the PIXI.DisplayObject the filter
     * is assigned to!
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 480,
     *     height: 270,
     *     transparent: false
     * }).setup().run()
     *
     * // Add a video sprite
     * const sprite = new PIXI.Sprite(PIXI.Texture.fromVideo("assets/blurfilter.mp4"))
     * sprite.width = app.size.width
     * sprite.height = app.size.height
     * app.scene.addChild(sprite)
     *
     * // Create the filter and assign it to the scene
     * const blurFilter = new BlurFilter(new PIXI.Rectangle(20, 20, 80, 60))
     * app.scene.filters = [blurFilter]
     *
     * @class
     * @extends PIXI.Filter
     * @param {PIXI.Rectangle|PIXI.Circle|PIXI.DisplayObject} shape The area where the blur effect should be applied to. Relative to the
     *     canvas, NOT relative to the PIXI.DisplayObject where the blur effect is assigned to!
     * @param {number} [blur=50] The strength of the blur.
     */
    class BlurFilter extends PIXI.Filter {
        constructor(shape, blur = 50) {
            super();

            const normalized = this.normalize(shape);

            this.tiltShiftXFilter = new TiltShiftXFilter(normalized, blur);
            this.tiltShiftYFilter = new TiltShiftYFilter(normalized, blur);
        }

        apply(filterManager, input, output) {
            let renderTarget = filterManager.getRenderTarget(true);
            this.tiltShiftXFilter.apply(filterManager, input, renderTarget);
            this.tiltShiftYFilter.apply(filterManager, renderTarget, output);
            filterManager.returnRenderTarget(renderTarget);
        }

        /**
         * The strength of the blur.
         *
         * @member {number}
         */
        get blur() {
            return this.tiltShiftXFilter.blur
        }
        set blur(value) {
            this.tiltShiftXFilter.blur = this.tiltShiftYFilter.blur = value;
        }

        /**
         * The blur shape.
         *
         * @member {PIXI.Rectangle|PIXI.Circle|PIXI.DisplayObject}
         */
        get shape() {
            return this.tiltShiftXFilter.shape
        }
        set shape(value) {
            this.tiltShiftXFilter.shape = this.tiltShiftYFilter.shape = this.normalize(
                value
            );
        }

        /**
         *
         * @private
         * @param {PIXI.Rectangle|PIXI.Circle|PIXI.DisplayObject} value
         * @returns {Object}
         */
        normalize(value) {
            let shape = null;

            if (value instanceof PIXI.Circle) {
                shape = { type: 'circle', x: value.x, y: value.y, r: value.radius };
            } else if (value instanceof PIXI.Rectangle) {
                shape = {
                    type: 'rectangle',
                    x: value.x,
                    y: value.y,
                    width: value.width,
                    height: value.height
                };
            } else {
                const bounds = value.getBounds();
                shape = {
                    type: 'rectangle',
                    x: bounds.x,
                    y: bounds.y,
                    width: bounds.width,
                    height: bounds.height
                };
            }

            return shape
        }
    }

    /**
     * A TiltShiftAxisFilter.
     *
     * @class
     * @extends PIXI.Filter
     * @abstract
     * @private
     */
    class TiltShiftAxisFilter extends PIXI.Filter {
        constructor(shape, blur) {
            const vertex = `
            attribute vec2 aVertexPosition;
            attribute vec2 aTextureCoord;

            uniform mat3 projectionMatrix;

            varying vec2 vTextureCoord;

            void main(void) {
                gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
                vTextureCoord = aTextureCoord;
            }
        `;

            const fragment = `
            varying vec2 vTextureCoord;

            uniform vec4 filterArea;
            uniform sampler2D uSampler;
            uniform int shape;
            uniform vec4 rectangle;
            uniform vec3 circle;
            uniform float blur;
            uniform vec2 delta;
            uniform vec2 texSize;

            float random(vec3 scale, float seed) {
                return fract(sin(dot(gl_FragCoord.xyz + seed, scale)) * 43758.5453 + seed);
            }

            void main(void) {
                // textureCoord to pixelCoord
                vec2 pixelCoord = vTextureCoord * filterArea.xy - vec2(4.0, 4.0);   // FIXME: There's a shift of 4 * 4 pixels, don't know why...

                bool inside = false;

                if (shape == 1) {
                    inside = distance(pixelCoord, circle.xy) <= circle.z;
                } else if (shape == 2) {
                    inside = pixelCoord.x >= rectangle.x && pixelCoord.x <= rectangle.z && pixelCoord.y >= rectangle.y && pixelCoord.y <= rectangle.w;
                }

                if (inside) {
                    vec4 color = vec4(0.0);
                    float total = 0.0;

                    float offset = random(vec3(12.9898, 78.233, 151.7182), 0.0);

                    for (float t = -30.0; t <= 30.0; t++) {
                        float percent = (t + offset - 0.5) / 30.0;
                        float weight = 1.0 - abs(percent);
                        vec4 sample = texture2D(uSampler, vTextureCoord + delta / texSize * percent * blur);
                        sample.rgb *= sample.a;
                        color += sample * weight;
                        total += weight;
                    }

                    gl_FragColor = color / total;
                    gl_FragColor.rgb /= gl_FragColor.a + 0.00001;
                } else {
                    gl_FragColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
                }
            }
        `;

            super(vertex, fragment);

            if (shape.type === 'circle') {
                this.uniforms.shape = 1;
                this.uniforms.circle = [shape.x, shape.y, shape.r];
            } else {
                this.uniforms.shape = 2;
                this.uniforms.rectangle = [
                    shape.x,
                    shape.y,
                    shape.x + shape.width,
                    shape.y + shape.height
                ];
            }
            this.uniforms.blur = blur;
            this.uniforms.delta = new PIXI.Point(0, 0);
            this.uniforms.texSize = new PIXI.Point(480, 270);

            this.updateDelta();
        }

        /**
         * The strength of the blur.
         *
         * @member {number}
         * @memberof PIXI.filters.TiltShiftAxisFilter#
         */
        get blur() {
            return this.uniforms.blur
        }
        set blur(value) {
            this.uniforms.blur = value;
        }

        /**
         * The blur shape.
         *
         * @member {PIXI.Rectangle}
         * @memberof PIXI.filters.TiltShiftAxisFilter#
         */
        get shape() {
            if (this.uniforms.shape === 1) {
                const circle = this.uniforms.circle;
                return new PIXI.Circle(circle[0], circle[1], circle[2])
            } else {
                const rectangle = this.uniforms.rectangle;
                return new PIXI.Rectangle(
                    rectangle[0],
                    rectangle[1],
                    rectangle[2],
                    rectangle[3]
                )
            }
        }
        set shape(value) {
            if (value.type === 'circle') {
                this.uniforms.shape = 1;
                this.uniforms.circle = [value.x, value.y, value.r];
            } else {
                this.uniforms.shape = 2;
                this.uniforms.rectangle = [
                    value.x,
                    value.y,
                    value.x + value.width,
                    value.y + value.height
                ];
            }
        }
    }

    /**
     * A TiltShiftXFilter.
     *
     * @class
     * @extends PIXI.TiltShiftAxisFilter
     * @private
     */
    class TiltShiftXFilter extends TiltShiftAxisFilter {
        /**
         * Updates the filter delta values.
         */
        updateDelta() {
            this.uniforms.delta.x = 0.1;
            this.uniforms.delta.y = 0;
        }
    }

    /**
     * A TiltShiftYFilter.
     *
     * @class
     * @extends PIXI.TiltShiftAxisFilter
     * @private
     */
    class TiltShiftYFilter extends TiltShiftAxisFilter {
        /**
         * Updates the filter delta values.
         */
        updateDelta() {
            this.uniforms.delta.x = 0;
            this.uniforms.delta.y = 0.1;
        }
    }

    // In order to test this interface implementation run jsc interface.js

    class Interface {
        // Abstract interface that should be extended in interface subclasses.
        // By convention all interfaces should start with an upper 'I'

        static implementationError(klass) {
            let interfaceKeys = Reflect.ownKeys(this.prototype);
            let classKeys = Reflect.ownKeys(klass.prototype);
            for (let key of interfaceKeys) {
                let interfaceDesc = this.prototype[key];
                let classDesc = klass.prototype[key];
                if (typeof classDesc == 'undefined') return 'Missing ' + key
            }
            return null
        }

        static implementedBy(klass) {
            // In the first step only checks whether the methods of this
            // interface are all implemented by the given class
            let error = this.implementationError(klass);
            return error == null
        }

        // TODO: Specify optional methods
        //     static optionalMethods() {
        //         return [this.onMouseWheel]
        //     }
    }

    /* eslint-disable no-undef */
    /* eslint-disable no-console */
    /* eslint-disable no-unused-vars */
    let ipc = null;
    let logMessages = new Set();
    let logHandlers = {
        log: console.log,
        warn: console.warn,
        error: console.error
    };

    try {
        ipc = require('electron').ipcRenderer;
        logHandlers.log = message => ipc.send('log', message);
        logHandlers.warn = message => ipc.send('warn', message);
        logHandlers.error = message => ipc.send('error', message);
    } catch (e) {
        console.log('Cannot use electron logging.');
    }

    /** Basic class for app specific logging requirements.
     * Can be used to implement persistent logging in electron apps.
     * Uses a logMessage cache to prevent error overflows. This is
     * needed since errors may occur very frequently
     * (e.g. display update loops at 60fps, programmatic loops, ...).
     *
     * The logging handlers can be overwritten by calling the static
     * setup method.
     */
    class Logging$1 {
        /** Static log function.
         * @param {*} message
         */
        static log(message) {
            logHandlers.log(message);
        }

        /**
         * Static warn function.
         * Emits each warning only once per session.
         * @param {*} message
         */
        static warn(message) {
            if (!logMessages.has(message)) {
                logMessages.add(message);
                logHandlers.warn(message);
            }
        }

        /**
         * Static error function.
         * Emits each error message only once per session.
         * @param {*} message
         */
        static error(message) {
            if (!logMessages.has(message)) {
                logMessages.add(message);
                logHandlers.error(message);
            }
        }

        static setup({
            log = console.log,
            warn = console.warn,
            error = console.error
        } = {}) {
            logHandlers.log = log;
            logHandlers.warn = warn;
            logHandlers.error = error;
        }
    }

    /* eslint-disable no-unused-vars */

    /** Interaction patterns

        See interaction.html for explanation
    */

    class IInteractionTarget extends Interface {
        capture(event) {
            return typeof true
        }

        onStart(event, interaction) {}
        onMove(event, interaction) {}
        onEnd(event, interaction) {}

        onMouseWheel(event) {}
    }

    class IInteractionMapperTarget extends Interface {
        capture(event) {
            return typeof true
        }

        findTarget(event, local, global) {
            return IInteractionTarget
        }
    }

    class PointMap extends MapProxy {
        // Collects touch points, mouse coordinates, etc. as key value pairs.
        // Keys are pointer and touch ids, the special "mouse" key.
        // Values are points, i.e. all objects with numeric x and y properties.
        constructor(points = {}) {
            super();
            for (let key in points) {
                this.set(key, points[key]);
            }
        }

        toString() {
            let points = [];
            for (let key of this.keys()) {
                let value = this.get(key);
                points.push(`${key}:{x:${value.x}, y:${value.y}}`);
            }
            let attrs = points.join(', ');
            return `[PointMap ${attrs}]`
        }

        clone() {
            let result = new PointMap();
            for (let key of this.keys()) {
                let value = this.get(key);
                result.set(key, { x: value.x, y: value.y });
            }
            return result
        }

        keyOf(value) {
            for (let key of this.keys()) {
                let p = this.get(key);
                if (p.x == value.x && p.y == value.y) {
                    return key
                }
            }
            return null
        }

        firstKey() {
            for (let key of this.keys()) {
                return key
            }
            return null
        }

        first() {
            for (let key of this.keys()) {
                return this.get(key)
            }
            return null
        }

        farthests() {
            if (this.size == 0) {
                return null
            }
            let pairs = [];
            for (let key of this.keys()) {
                let p = this.get(key);
                p.key = key;
                for (let k of this.keys()) {
                    let q = this.get(k);
                    q.key = k;
                    pairs.push([p, q]);
                }
            }
            let sorted = pairs.sort((a, b) => {
                return Points.distance(b[0], b[1]) - Points.distance(a[0], a[1])
            });
            return sorted[0]
        }

        mean() {
            if (this.size == 0) {
                return null
            }
            let x = 0.0,
                y = 0.0;
            for (let p of this.values()) {
                x += p.x;
                y += p.y;
            }
            return { x: x / this.size, y: y / this.size }
        }
    }

    class InteractionDelta {
        /**
         *Creates an instance of InteractionDelta.
         * @param {*} x
         * @param {*} y
         * @param {*} zoom
         * @param {*} rotate
         * @param {*} about
         * @param {*} number - number of involved pointer
         * @param {*} distance - distance of farthests touch points
         * @memberof InteractionDelta
         */
        constructor(x, y, zoom, rotate, about, number, distance) {
            this.x = x;
            this.y = y;
            this.zoom = zoom;
            this.rotate = rotate;
            this.about = about;
            this.number = number;
            this.distance = distance;
        }

        toString() {
            let values = [];
            for (let key of Object.keys(this)) {
                let value = this[key];
                if (key == 'about') {
                    values.push(`${key}:{x:${value.x}, y:${value.y}}`);
                } else {
                    values.push(`${key}:${value}`);
                }
            }
            let attrs = values.join(', ');
            return `[InteractionDelta ${attrs}]`
        }
    }

    class InteractionPoints {
        constructor(parent = null) {
            this.parent = parent;
            this.current = new PointMap();
            this.previous = new PointMap();
            this.start = new PointMap();
            this.ended = new PointMap();
            this.timestamps = new Map();
        }

        moved(key) {
            let current = this.current.get(key);
            let previous = this.previous.get(key);
            return Points.subtract(current, previous)
        }

        move() {
            let current = this.current.mean();
            let previous = this.previous.mean();
            return Points.subtract(current, previous)
        }

        /**
         * Computes the delta between previous and current angles. Corrects
         * value that are larger than 45°
         * @param {*} a
         * @param {*} b
         * @returns delta
         */
        diffAngle(a, b) {
            let alpha = Math.atan2(Math.sin(a - b), Math.cos(a - b));
            if (Math.abs(alpha) > Math.PI / 4) {
                alpha -= Math.PI;
            }
            return alpha
        }

        /**
         * Computes the delta between interaction points at t and t+1.
         *
         * @returns InteractionDelta
         * @memberof InteractionPoints
         */
        delta() {
            let prev = [];
            let curr = [];
            let cm = { x: 0, y: 0};
            let pm = { x: 0, y: 0};
            let count = 0;
            for(let key of this.current.keys()) {
                if (this.previous.has(key)) {
                    let p = this.previous.get(key);
                    let c = this.current.get(key);
                    pm = Points.add(pm, p);
                    cm = Points.add(cm, c);
                    prev.push(p);
                    curr.push(c);
                    count += 1;
                }
            }
            if (count > 0) {
                pm = Points.multiplyScalar(pm, 1 / count);
                cm = Points.multiplyScalar(cm, 1 / count);
                let delta = Points.subtract(cm, pm);
                let scale = 0;
                let scaled = 0;
                let alpha = 0;
                let zoom = 1;
                for(let i=0; i<count; i++) {
                    let p = prev[i];
                    let c = curr[i];
                    let previousAngle = Points.angle(p, pm);
                    let currentAngle = Points.angle(c, cm);
                    let diff = this.diffAngle(currentAngle, previousAngle);
                    alpha += diff;

                    let distance1 = Points.distance(p, pm);
                    let distance2 = Points.distance(c, cm);
                    if (distance1 != 0 && distance2 != 0) {
                        scale += distance2 / distance1;
                        scaled += 1;
                    }
                }
                if (scaled > 0) {
                    zoom = scale / scaled;
                }
                alpha /= count;

                let current = this.current.farthests();

                let c1 = current[0];
                let c2 = current[1];
                let distance2 = Points.distance(c1, c2);

                return new InteractionDelta(
                    delta.x,
                    delta.y,
                    zoom,
                    alpha,
                    cm,
                    count,
                    distance2
                )
            }
            else {
                return null
            }
        }

        /**
         * Computes the delta between interaction points at t and t+1.
         *
         * @returns InteractionDelta
         * @memberof InteractionPoints
         */
        deltaByTwoFarthestsPoints() {
            let csize = this.current.size;
            let psize = this.previous.size;
            if (csize >= 2 && csize == psize) {
                // Reduce to the two farthests points
                let current = this.current.farthests();

                let c1 = current[0];
                let c2 = current[1];

                let p1 = this.previous.get(c1.key);
                let p2 = this.previous.get(c2.key);

                let d1 = Points.subtract(c1, p1);
                let d2 = Points.subtract(c2, p2);
                let cm = Points.mean(c1, c2);

                // Using the mean leads to jumps between time slices with 3 and 2 fingers
                // We use the mean of deltas instead
                let delta = Points.mean(d1, d2);
                let zoom = 1.0;
                let distance1 = Points.distance(p1, p2);
                let distance2 = Points.distance(c1, c2);
                if (distance1 != 0 && distance2 != 0) {
                    zoom = distance2 / distance1;
                }
                let currentAngle = Points.angle(c1, c2);
                let previousAngle = Points.angle(p1, p2);
                let alpha = this.diffAngle(currentAngle, previousAngle);
                return new InteractionDelta(
                    delta.x,
                    delta.y,
                    zoom,
                    alpha,
                    cm,
                    csize,
                    distance2
                )
            } else if (
                csize == 1 &&
                psize == 1 &&
                this.current.firstKey() == this.previous.firstKey()
            ) {
                // We need to ensure that the keys are the same, since single points with different keys
                // can jump
                let current = this.current.first();
                let previous = this.previous.first();
                let delta = Points.subtract(current, previous);
                return new InteractionDelta(
                    delta.x,
                    delta.y,
                    1.0,
                    0.0,
                    current,
                    csize
                )
            }
            return null
        }

        started(key, point) {
            this.current.set(key, point);
            this.start.set(key, point);
            this.previous.set(key, point);
            this.timestamps.set(key, performance.now());
        }

        update(key, point) {
            // Returns true iff the key is new
            this.current.set(key, point);
            if (!this.start.has(key)) {
                this.start.set(key, point);
                this.previous.set(key, point);
                this.timestamps.set(key, performance.now());
                return true
            }
            return false
        }

        updatePrevious() {
            for (let key of this.current.keys()) {
                this.previous.set(key, this.current.get(key));
            }
        }

        stop(key, point) {
            if (this.current.has(key)) {
                this.current.delete(key);
                this.previous.delete(key);
                this.ended.set(key, point);
            }
        }

        finish(key, point) {
            this.current.delete(key);
            this.previous.delete(key);
            this.start.delete(key);
            this.timestamps.delete(key);
            this.ended.delete(key);
        }

        isFinished() {
            return this.current.size == 0
        }

        isNoLongerTwoFinger() {
            return this.previous.size > 1 && this.current.size < 2
        }

        isTap(key) {
            return this.parent.isTap(key)
        }

        isDoubleTap(key) {
            return this.parent.isDoubleTap(key)
        }

        isLongPress(key) {
            return this.parent.isLongPress(key)
        }
    }

    class Interaction extends InteractionPoints {
        constructor(tapDistance = 10, tapDuration = 250.0, longPressTime = 500.0) {
            super();
            this.tapDistance = tapDistance;
            this.tapCounts = new Map();
            this.tapPositions = new Map();
            this.tapTimestamps = new Map();
            this.tapDuration = tapDuration;
            this.longPressTime = longPressTime;
            this.targets = new Map();
            this.subInteractions = new Map(); // target:Object : InteractionPoints
        }

        stop(key, point) {
            super.stop(key, point);
            for (let points of this.subInteractions.values()) {
                points.stop(key, point);
            }
        }

        addTarget(key, target) {
            this.targets.set(key, target);
            this.subInteractions.set(target, new InteractionPoints(this));
        }

        removeTarget(key) {
            let target = this.targets.get(key);
            this.targets.delete(key);
            // Only remove target if no keys are refering to the target
            let remove = true;
            for (let t of this.targets.values()) {
                if (target === t) {
                    remove = false;
                }
            }
            if (remove) {
                this.subInteractions.delete(target);
            }
        }

        finish(key, point) {
            super.finish(key, point);
            this.removeTarget(key);
        }

        mapInteraction(points, aspects, mappingFunc) {
            // Map centrally registered points to target interactions
            // Returns an array of [target, updated subInteraction] pairs
            let result = new Map();
            for (let key in points) {
                if (this.targets.has(key)) {
                    let target = this.targets.get(key);
                    if (this.subInteractions.has(target)) {
                        let interaction = this.subInteractions.get(target);
                        for (let aspect of aspects) {
                            let pointMap = this[aspect];
                            let point = pointMap.get(key);
                            let mapped = mappingFunc(point, target);
                            interaction[aspect].set(key, mapped);
                        }
                        result.set(target, interaction);
                    }
                }
            }
            return result
        }

        registerTap(key, point) {
            if (this.tapCounts.has(key)) {
                let count = this.tapCounts.get(key);
                this.tapCounts.set(key, count + 1);
            } else {
                this.tapCounts.set(key, 1);
            }
            this.tapPositions.set(key, point);
            this.tapTimestamps.set(key, performance.now());
        }

        unregisterTap(key) {
            this.tapCounts.delete(key);
            this.tapPositions.delete(key);
            this.tapTimestamps.delete(key);
        }

        isTap(key) {
            let ended = this.ended.get(key);
            let start = this.start.get(key);
            if (
                start &&
                ended &&
                Points.distance(ended, start) < this.tapDistance
            ) {
                let t1 = this.timestamps.get(key);
                let tookLong = performance.now() > t1 + this.longPressTime;
                if (tookLong) {
                    return false
                }
                return true
            }
            return false
        }

        isDoubleTap(key) {
            let ended = this.ended.get(key);
            if (this.tapCounts.has(key) && this.tapCounts.get(key) > 2) {
                this.unregisterTap(key);
            }
            if (this.tapPositions.has(key)) {
                let pos = this.tapPositions.get(key);
                if (Points.distance(ended, pos) > this.tapDistance) {
                    this.unregisterTap(key);
                }
            }
            if (
                this.tapTimestamps.has(key) &&
                performance.now() > this.tapTimestamps.get(key) + this.tapDuration
            ) {
                //console.log("tap too long")
                this.unregisterTap(key);
            }
            let result = false;
            if (this.isTap(key)) {
                this.registerTap(key, ended);
                result = this.tapCounts.get(key) == 2;
            } else {
                this.unregisterTap(key);
            }
            //console.log("isDoubleTap", this.tapCounts.get(key), result)
            return result
        }

        isAnyTap() {
            for (let key of this.ended.keys()) {
                if (this.isTap(key)) return true
            }
            return false
        }

        isLongPress(key) {
            let ended = this.ended.get(key);
            let start = this.start.get(key);
            if (
                start &&
                ended &&
                Points.distance(ended, start) < this.tapDistance
            ) {
                let t1 = this.timestamps.get(key);
                let tookLong = performance.now() > t1 + this.longPressTime;
                if (tookLong) {
                    return true
                }
                return false
            }
            return false
        }

        isAnyLongPress() {
            for (let key of this.ended.keys()) {
                if (this.isLongPress(key)) return true
            }
            return false
        }

        isStylus(key) {
            return key === 'stylus'
        }
    }

    /**
     * This class implements the main delegate functionality: All necessary event handlers are registered for the
     * given element. Uses PointerEvents if available or TouchEvents on iOS. The fallback is on mouse events.
     * Collects the events if the interaction target captures the start event (i.e. declares that
     * the target wants the start event as well as all following move and end evcents.)
     *
     * @export
     * @class InteractionDelegate
     */
    class InteractionDelegate {
        // Long press: http://stackoverflow.com/questions/1930895/how-long-is-the-event-onlongpress-in-the-android
        // Stylus support: https://w3c.github.io/touch-events/

        /**
         * Creates an instance of InteractionDelegate.
         * @param {any} element
         * @param {any} target
         * @param {any} [{ mouseWheelElement = null, useCapture = true, capturePointerEvents = true, debug = false }={}]
         * @memberof InteractionDelegate
         */
        constructor(
            element,
            target,
            {
                mouseWheelElement = null,
                useCapture = true,
                capturePointerEvents = true,
                cancelOnWindowOut = true,
                debug = false
            } = {}
        ) {
            this.debug = debug;
            this.interaction = new Interaction();
            this.element = element;
            this.mouseWheelElement = mouseWheelElement || element;
            this.target = target;
            this.useCapture = useCapture;
            this.capturePointerEvents = capturePointerEvents;
            this.cancelOnWindowOut = cancelOnWindowOut;
            this.setupInteraction();
        }

        setupInteraction() {
            if (this.debug) {
                let error = this.targetInterface.implementationError(
                    this.target.constructor
                );
                if (error != null) {
                    throw new Error('Expected IInteractionTarget: ' + error)
                }
            }
            this.setupTouchInteraction();
            this.setupMouseWheelInteraction();
        }

        get targetInterface() {
            return IInteractionTarget
        }

        setupTouchInteraction() {
            let element = this.element;
            let useCapture = this.useCapture;
            if (window.PointerEvent) {
                if (this.debug) console.log('Pointer API' + window.PointerEvent);
                element.addEventListener(
                    'pointerdown',
                    e => {
                        if (this.debug) console.log('pointerdown', e.pointerId);
                        if (this.capture(e)) {
                            if (this.capturePointerEvents) {
                                try {
                                    element.setPointerCapture(e.pointerId);
                                } catch (e) {
                                    console.warn('Cannot setPointerCapture');
                                }
                            }
                            this.onStart(e);
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'pointermove',
                    e => {
                        if (this.debug)
                            console.log('pointermove', e.pointerId, e.pointerType);

                        if (
                            e.pointerType == 'touch' ||
                            (e.pointerType == 'mouse' && Events$1.isPointerDown(e))
                        ) {
                            // this.capture(e) &&
                            if (this.debug)
                                console.log('pointermove captured', e.pointerId);
                            this.onMove(e);
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'pointerup',
                    e => {
                        if (this.debug)
                            console.log('pointerup', e.pointerId, e.pointerType);
                        this.onEnd(e);
                        if (this.capturePointerEvents) {
                            try {
                                element.releasePointerCapture(e.pointerId);
                            } catch (e) {
                                console.warn('Cannot release pointer');
                            }
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'pointercancel',
                    e => {
                        if (this.debug)
                            console.log('pointercancel', e.pointerId, e.pointerType);
                        this.onEnd(e);
                        if (this.capturePointerEvents)
                            element.releasePointerCapture(e.pointerId);
                    },
                    useCapture
                );

                if (!this.capturePointerEvents) {
                    element.addEventListener(
                        'pointerleave',
                        e => {
                            if (this.debug)
                                console.log(
                                    'pointerleave',
                                    e.pointerId,
                                    e.pointerType
                                );
                            if (e.target == element) this.onEnd(e);
                        },
                        useCapture
                    );
                }

                if (!this.capturePointerEvents) {
                    element.addEventListener(
                        'pointerout',
                        e => {
                            if (this.debug)
                                console.log(
                                    'pointerout',
                                    e.pointerId,
                                    e.pointerType
                                );
                            if (e.target == element) this.onEnd(e);
                        },
                        useCapture
                    );
                }

                if (this.cancelOnWindowOut) {
                    window.addEventListener(
                        'pointerout',
                        e => {
                            if (this.debug)
                                console.log(
                                    'pointerout',
                                    e.pointerId,
                                    e.pointerType,
                                    e.target
                                );
                            if (e.target == element) {
                                this.onEnd(e);
                            }
                        },
                        useCapture
                    );
                }
            } else if (window.TouchEvent) {
                if (this.debug) console.log('Touch API');
                element.addEventListener(
                    'touchstart',
                    e => {
                        if (this.debug)
                            console.log('touchstart', this.touchPoints(e));
                        if (this.capture(e)) {
                            for (let touch of e.changedTouches) {
                                this.onStart(touch);
                            }
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'touchmove',
                    e => {
                        if (this.debug)
                            console.log('touchmove', this.touchPoints(e), e);
                        for (let touch of e.changedTouches) {
                            this.onMove(touch);
                        }
                        for (let touch of e.targetTouches) {
                            this.onMove(touch);
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'touchend',
                    e => {
                        if (this.debug) console.log('touchend', this.touchPoints(e));
                        for (let touch of e.changedTouches) {
                            this.onEnd(touch);
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'touchcancel',
                    e => {
                        if (this.debug)
                            console.log(
                                'touchcancel',
                                e.targetTouches.length,
                                e.changedTouches.length
                            );
                        for (let touch of e.changedTouches) {
                            this.onEnd(touch);
                        }
                    },
                    useCapture
                );
            } else {
                if (this.debug) console.log('Mouse API');

                element.addEventListener(
                    'mousedown',
                    e => {
                        if (this.debug) console.log('mousedown', e);
                        if (this.capture(e)) {
                            this.onStart(e);
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'mousemove',
                    e => {
                        // Dow we only use move events if the mouse is down?
                        // HOver effects have to be implemented by other means
                        // && Events.isMouseDown(e))

                        if (Events$1.isMouseDown(e)) {
                            if (this.debug) console.log('mousemove', e);
                            this.onMove(e);
                        }
                    },
                    useCapture
                );
                element.addEventListener(
                    'mouseup',
                    e => {
                        if (this.debug) console.log('mouseup', e);
                        this.onEnd(e);
                    },
                    true
                );

                if (!this.capturePointerEvents) {
                    element.addEventListener(
                        'mouseout',
                        e => {
                            if (e.target == element) {
                                this.onEnd(e);
                                console.warn(
                                    "Shouldn't happen: mouseout ends interaction"
                                );
                            }
                        },
                        useCapture
                    );
                }
                if (this.cancelOnWindowOut) {
                    window.addEventListener(
                        'mouseout',
                        e => {
                            if (e.target == element) {
                                this.onEnd(e);
                            }
                        },
                        useCapture
                    );
                }
            }
        }

        isDescendant(parent, child) {
            if (parent == child) return true
            let node = child.parentNode;
            while (node != null) {
                if (node == parent) {
                    return true
                }
                node = node.parentNode;
            }
            return false
        }

        touchPoints(event) {
            let result = [];
            for (let touch of event.changedTouches) {
                result.push(this.extractPoint(touch));
            }
            return result
        }

        setupMouseWheelInteraction() {
            this.mouseWheelElement.addEventListener(
                'mousewheel',
                this.onMouseWheel.bind(this),
                true
            );
            this.mouseWheelElement.addEventListener(
                'DOMMouseScroll',
                this.onMouseWheel.bind(this),
                true
            );
        }

        onMouseWheel(event) {
            if (this.capture(event) && this.target.onMouseWheel) {
                this.target.onMouseWheel(event);
            }
        }

        onStart(event) {
            let extracted = this.extractPoint(event);
            this.startInteraction(event, extracted);
            this.target.onStart(event, this.interaction);
        }

        onMove(event) {
            let extracted = this.extractPoint(event, 'all');
            this.updateInteraction(event, extracted);
            this.target.onMove(event, this.interaction);
            this.interaction.updatePrevious();
        }

        onEnd(event) {
            let extracted = this.extractPoint(event, 'changedTouches');
            this.endInteraction(event, extracted);
            this.target.onEnd(event, this.interaction);
            this.finishInteraction(event, extracted);
        }

        /**
         * Asks the target whether the event should be captured
         *
         * @param {any} event
         * @returns {bool}
         * @memberof InteractionDelegate
         */
        capture(event) {
            if (Events$1.isCaptured(event)) {
                return false
            }
            let captured = this.target.capture(event);
            return captured
        }

        getPosition(event) {
            return { x: event.clientX, y: event.clientY }
        }

        extractPoint(event, touchEventKey = 'all') {
            // 'targetTouches'
            let result = {};
            switch (event.constructor.name) {
                case 'MouseEvent': {
                    let buttons = event.buttons || event.which;
                    if (buttons) result['mouse'] = this.getPosition(event);
                    break
                }
                case 'PointerEvent': {
                    result[event.pointerId.toString()] = this.getPosition(event);
                    break
                }
                case 'Touch': {
                    let id =
                        event.touchType === 'stylus'
                            ? 'stylus'
                            : event.identifier.toString();
                    result[id] = this.getPosition(event);
                    break
                }
                //             case 'TouchEvent':
                //                 // Needs to be observed: Perhaps changedTouches are all we need. If so
                //                 // we can remove the touchEventKey default parameter
                //                 if (touchEventKey == 'all') {
                //                     for(let t of event.targetTouches) {
                //                         result[t.identifier.toString()] = this.getPosition(t)
                //                     }
                //                     for(let t of event.changedTouches) {
                //                         result[t.identifier.toString()] = this.getPosition(t)
                //                     }
                //                 }
                //                 else {
                //                     for(let t of event.changedTouches) {
                //                         result[t.identifier.toString()] = this.getPosition(t)
                //                     }
                //                 }
                //                 break
                default:
                    break
            }
            return result
        }

        interactionStarted(event, key, point) {
            // Callback: can be overwritten
        }

        interactionEnded(event, key, point) {
            // Callback: can be overwritten
        }

        interactionFinished(event, key, point) {}

        startInteraction(event, extracted) {
            for (let key in extracted) {
                let point = extracted[key];
                this.interaction.started(key, point);
                this.interactionStarted(event, key, point);
            }
        }

        updateInteraction(event, extracted) {
            for (let key in extracted) {
                let point = extracted[key];
                let updated = this.interaction.update(key, point);
                if (updated) {
                    console.warn(
                        "new pointer in updateInteraction shouldn't happen",
                        key
                    );
                    this.interactionStarted(event, key, point);
                }
            }
        }

        endInteraction(event, ended) {
            for (let key in ended) {
                let point = ended[key];
                this.interaction.stop(key, point);
                this.interactionEnded(event, key, point);
            }
        }

        finishInteraction(event, ended) {
            for (let key in ended) {
                let point = ended[key];
                this.interaction.finish(key, point);
                this.interactionFinished(event, key, point);
            }
        }
    }
    /**
     * A special InteractionDelegate that maps events to specific parts of
     * the interaction target. The InteractionTarget must implement a findTarget
     * method that returns an object implementing the IInteractionTarget interface.
     *
     * If the InteractionTarget also implements a mapPositionToPoint method this
     * is used to map the points to the local coordinate space of the the target.
     *
     * This makes it easier to lookup elements and relate events to local
     * positions.
     *
     * @export
     * @class InteractionMapper
     * @extends {InteractionDelegate}
     */
    class InteractionMapper$1 extends InteractionDelegate {
        constructor(
            element,
            target,
            {
                tapDistance = 10,
                longPressTime = 500.0,
                useCapture = true,
                mouseWheelElement = null,
                logInteractionsAbove = 12
            } = {}
        ) {
            super(element, target, {
                tapDistance,
                useCapture,
                longPressTime,
                mouseWheelElement
            });
            this.logInteractionsAbove = logInteractionsAbove;
        }

        get targetInterface() {
            return IInteractionMapperTarget
        }

        mapPositionToPoint(point, element = null) {
            if (this.target.mapPositionToPoint) {
                return this.target.mapPositionToPoint(point, element)
            }
            return point
        }

        interactionStarted(event, key, point) {
            if (this.target.findTarget) {
                let local = this.mapPositionToPoint(point);
                let found = this.target.findTarget(event, local, point);
                if (found != null) {
                    this.interaction.addTarget(key, found);
                }
            }
            let size = this.interaction.current.size;
            let limit = this.logInteractionsAbove;
            if (size > limit) {
                Logging$1.log(`Number of interactions ${size} exceeds ${limit}`);
            }
        }

        onMouseWheel(event) {
            if (this.capture(event)) {
                if (this.target.findTarget) {
                    let point = this.getPosition(event);
                    let local = this.mapPositionToPoint(point);
                    let found = this.target.findTarget(event, local, point);
                    if (found != null && found.onMouseWheel) {
                        found.onMouseWheel(event);
                        return
                    }
                }
                if (this.target.onMouseWheel) {
                    this.target.onMouseWheel(event);
                }
            }
        }

        onStart(event) {
            let extracted = this.extractPoint(event);
            this.startInteraction(event, extracted);
            let mapped = this.interaction.mapInteraction(
                extracted,
                ['current', 'start'],
                this.mapPositionToPoint.bind(this)
            );
            for (let [target, interaction] of mapped.entries()) {
                target.onStart(event, interaction);
            }
        }

        onMove(event) {
            let extracted = this.extractPoint(event, 'all');
            this.updateInteraction(event, extracted);
            let mapped = this.interaction.mapInteraction(
                extracted,
                ['current', 'previous'],
                this.mapPositionToPoint.bind(this)
            );
            for (let [target, interaction] of mapped.entries()) {
                target.onMove(event, interaction);
                interaction.updatePrevious();
            }
            this.interaction.updatePrevious();
        }

        onEnd(event) {
            let extracted = this.extractPoint(event, 'changedTouches');
            this.endInteraction(event, extracted);
            let mapped = this.interaction.mapInteraction(
                extracted,
                ['ended'],
                this.mapPositionToPoint.bind(this)
            );
            for (let [target, interaction] of mapped.entries()) {
                target.onEnd(event, interaction);
            }
            this.finishInteraction(event, extracted);
        }

        /**
         *
         *
         * @static
         * @param {string|array} types - An event type, an array of event types or event types seperated by a space sign. The following
         *     events are possible:
         *         pan, panstart, panmove, panend, pancancel, panleft, panright, panup, pandown
         *         pinch, pinchstart, pinchmove, pinchend, pinchcancel, pinchin, pinchout
         *         press, pressup
         *         rotate, rotatestart, rotatemove, rotateend, rotatecancel
         *         swipe, swipeleft, swiperight, swipeup, swipedown
         *         tap
         * @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
         * @param {function} [cb] - The callback. A function which is executed after the event occurs. Receives the event object as the
         *     first paramter
         * @param {object} [opts] - An options object. See the hammer documentation for more details.
         */
        static on(types, elements, cb, opts = {}) {
            opts = Object.assign({}, {}, opts);

            if (typeof Hammer === 'undefined') {
                console.error('Hammer.js not found!');
                return this
            }

            // convert to array
            types = Array.isArray(types) ? types : types.split(/\s/);
            if (
                elements instanceof NodeList ||
                elements instanceof HTMLCollection
            ) {
                elements = Array.from(elements);
            }
            elements = Array.isArray(elements) ? elements : [elements];

            for (let i = 0; i < types.length; i++) {
                const type = types[i].toLowerCase();

                // list of hammer events
                const useHammer = /^(tap|doubletap|press|pan|swipe|pinch|rotate).*$/.test(
                    type
                );

                // if it is a hammer event
                if (useHammer) {
                    for (let j = 0; j < elements.length; j++) {
                        // if(elements[j].tagName == "svg") return false;

                        let hammer = new Hammer(elements[j], opts);

                        if (window.propagating !== 'undefined') {
                            hammer = propagating(hammer);
                        }

                        // recognizers
                        if (type.startsWith('pan')) {
                            hammer
                                .get('pan')
                                .set(
                                    Object.assign(
                                        { direction: Hammer.DIRECTION_ALL },
                                        opts
                                    )
                                );
                        } else if (type.startsWith('pinch')) {
                            hammer
                                .get('pinch')
                                .set(Object.assign({ enable: true }, opts));
                        } else if (type.startsWith('press')) {
                            hammer.get('press').set(opts);
                        } else if (type.startsWith('rotate')) {
                            hammer
                                .get('rotate')
                                .set(Object.assign({ enable: true }, opts));
                        } else if (type.startsWith('swipe')) {
                            hammer
                                .get('swipe')
                                .set(
                                    Object.assign(
                                        { direction: Hammer.DIRECTION_ALL },
                                        opts
                                    )
                                );
                        } else if (type.startsWith('tap')) {
                            hammer.get('tap').set(opts);
                        }

                        hammer.on(type, event => {
                            cb(event);
                        });
                    }
                } else {
                    for (let j = 0; j < elements.length; j++) {
                        Hammer.on(elements[j], type, event => {
                            cb(event);
                        });
                    }
                }
            }

            return this
        }
    }

    window.InteractionMapper = InteractionMapper$1;

    /** Report capabilities with guaranteed values.
     */
    class Capabilities {
        /** Returns the browser userAgent.
        @return {string}
        */
        static get userAgent() {
            return navigator.userAgent || 'Unknown Agent'
        }

        /** Tests whether the app is running on a mobile device.
        Implemented as a readonly attribute.
        @return {boolean}
        */
        static get isMobile() {
            return /Mobi/.test(navigator.userAgent)
        }

        /** Tests whether the app is running on a iOS device.
        Implemented as a readonly attribute.
        @return {boolean}
        */
        static get isIOS() {
            return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
        }

        /** Tests whether the app is running in a Safari environment.
        See https://stackoverflow.com/questions/7944460/detect-safari-browser
        Implemented as a readonly attribute.
        @return {boolean}
        */
        static get isSafari() {
            return (
                navigator.vendor &&
                navigator.vendor.indexOf('Apple') > -1 &&
                navigator.userAgent &&
                !navigator.userAgent.match('CriOS')
            )
        }

        /**
         * Distincts if the app is running inside electron or not.
         *
         * source: https://github.com/cheton/is-electron
         */
        static get isElectron() {
            // Renderer process
            if (
                typeof window !== 'undefined' &&
                typeof window.process === 'object' &&
                window.process.type === 'renderer'
            ) {
                return true
            }

            // Main process
            if (
                typeof process !== 'undefined' &&
                typeof process.versions === 'object' &&
                !!process.versions.electron
            ) {
                return true
            }

            // Detect the user agent when the `nodeIntegration` option is set to true
            if (
                typeof navigator === 'object' &&
                typeof navigator.userAgent === 'string' &&
                navigator.userAgent.indexOf('Electron') >= 0
            ) {
                return true
            }

            return false
        }

        /** Returns the display resolution. Necessary for retina displays.
        @return {number}
        */
        static get devicePixelRatio() {
            return window.devicePixelRatio || 1
        }

        /** Returns true if the device is a multi-touch table. This method is currently not universal usable and not sure!
        @return {boolean}
        */
        static get isMultiTouchTable() {
            return (
                Capabilities.devicePixelRatio > 2 &&
                Capabilities.isMobile === false &&
                /Windows/i.test(Capabilities.userAgent)
            )
        }

        /** Returns true if mouse events are supported
        @return {boolean}
        */
        static supportsMouseEvents() {
            return typeof window.MouseEvent != 'undefined'
        }

        /** Returns true if touch events are supported
        @return {boolean}
        */
        static supportsTouchEvents() {
            return typeof window.TouchEvent != 'undefined'
        }

        /** Returns true if pointer events are supported
        @return {boolean}
        */
        static supportsPointerEvents() {
            return typeof window.PointerEvent != 'undefined'
        }

        /** Returns true if DOM templates are supported
        @return {boolean}
        */
        static supportsTemplate() {
            return 'content' in document.createElement('template')
        }
    }

    /** Basic tests for Capabilities.
     */
    class CapabilitiesTests {
        static testConfirm() {
            let bool = confirm('Please confirm');
            document.getElementById('demo').innerHTML = bool
                ? 'Confirmed'
                : 'Not confirmed';
        }

        static testPrompt() {
            let person = prompt('Please enter your name', 'Harry Potter');
            if (person != null) {
                demo.innerHTML = 'Hello ' + person + '! How are you today?';
            }
        }

        static testUserAgent() {
            let agent = 'User-agent: ' + Capabilities.userAgent;
            user_agent.innerHTML = agent;
        }

        static testDevicePixelRatio() {
            let value = 'Device Pixel Ratio: ' + Capabilities.devicePixelRatio;
            device_pixel_ratio.innerHTML = value;
        }

        static testMultiTouchTable() {
            let value =
                'Is the device a multi-touch table? ' +
                Capabilities.isMultiTouchTable;
            multi_touch_table.innerHTML = value;
        }

        static testSupportedEvents() {
            let events = [];
            if (Capabilities.supportsMouseEvents()) {
                events.push('MouseEvents');
            }
            if (Capabilities.supportsTouchEvents()) {
                events.push('TouchEvents');
            }
            if (Capabilities.supportsPointerEvents()) {
                events.push('PointerEvents');
            }
            supported_events.innerHTML = 'Supported Events: ' + events.join(', ');
        }

        static testAll() {
            this.testUserAgent();
            this.testDevicePixelRatio();
            this.testMultiTouchTable();
            this.testSupportedEvents();
        }
    }

    /* Optional global variables, needed in DocTests. */
    window.Capabilities = Capabilities;
    window.CapabilitiesTests = CapabilitiesTests;

    /* eslint-disable no-unused-vars */

    /**
     * A base class for scatter specific events.
     *
     * @constructor
     * @param {name} String - The name of the event
     * @param {target} Object - The target of the event
     */
    class BaseEvent {
        constructor(name, target) {
            this.name = name;
            this.target = target;
        }
    }

    // Event types
    const START = 'onStart';
    const UPDATE = 'onUpdate';
    const END = 'onEnd';

    /**
     * A scatter event that describes how the scatter has changed.
     *
     * @constructor
     * @param {target} Object - The target scatter of the event
     * @param {optional} Object - Optional parameter
     */
    class ScatterEvent extends BaseEvent {
        constructor(
            target,
            {
                translate = { x: 0, y: 0 },
                scale = null,
                rotate = 0,
                about = null,
                fast = false,
                type = null
            } = {}
        ) {
            super('scatterTransformed', { target: target });
            this.translate = translate;
            this.scale = scale;
            this.rotate = rotate;
            this.about = about;
            this.fast = fast;
            this.type = type;
        }

        toString() {
            return (
                "Event('scatterTransformed', scale: " +
                this.scale +
                ' about: ' +
                this.about.x +
                ', ' +
                this.about.y +
                ')'
            )
        }
    }

    /**
     * A scatter resize event that describes how the scatter has changed.
     *
     * @constructor
     * @param {target} Object - The target scatter of the event
     * @param {optional} Object - Optional parameter
     */
    class ResizeEvent extends BaseEvent {
        constructor(target, { width = 0, height = 0 } = {}) {
            super('scatterResized', { width: width, height: height });
            this.width = width;
            this.height = height;
        }

        toString() {
            return (
                'Event(scatterResized width: ' +
                this.width +
                'height: ' +
                this.height +
                ')'
            )
        }
    }

    /**
     * A abstract base class that implements the throwable behavior of a scatter
     * object.
     *
     * @constructor
     */
    class Throwable {
        constructor({
            movableX = true,
            movableY = true,
            throwVisibility = 44,
            throwDamping = 0.95,
            autoThrow = true,
            onThrowFinished = null
        } = {}) {
            this.movableX = movableX;
            this.movableY = movableY;
            this.throwVisibility = throwVisibility;
            this.throwDamping = throwDamping;
            this.autoThrow = autoThrow;
            this.velocities = [];
            this.velocity = null;
            this.timestamp = null;
            this.onThrowFinished = onThrowFinished;
            //console.log("onThrowFinished", onThrowFinished)
        }

        observeVelocity() {
            this.lastframe = performance.now();
        }

        addVelocity(delta, buffer = 5) {
            let t = performance.now();
            let dt = t - this.lastframe;
            this.lastframe = t;
            if (dt > 0) {
                // Avoid division by zero errors later on
                // and consider the number of involved pointers sind addVelocity will be called by the
                // onMove events
                let velocity = {
                    t: t,
                    dt: dt,
                    dx: delta.x / delta.number,
                    dy: delta.y / delta.number
                };
                this.velocities.push(velocity);
                while (this.velocities.length > buffer) {
                    this.velocities.shift();
                }
            }
        }

        meanVelocity(milliseconds = 30) {
            this.addVelocity({ x: 0, y: 0, number: 1 });
            let sum = { x: 0, y: 0 };
            let count = 0;
            let t = 0;
            for (let i = this.velocities.length - 1; i > 0; i--) {
                let v = this.velocities[i];
                t += v.dt;
                let nv = { x: v.dx / v.dt, y: v.dy / v.dt };
                sum = Points.add(sum, nv);
                count += 1;
                if (t > milliseconds) {
                    break
                }
            }
            if (count === 0) return sum // empty vector
            return Points.multiplyScalar(sum, 1 / count)
        }

        killAnimation() {
            this.velocity = null;
            this.velocities = [];
        }

        startThrow() {
            this.velocity = this.meanVelocity();
            if (this.velocity != null) {
                // Call next velocity to ansure that specializations
                // that use keepOnStage are called
                this.velocity = this.nextVelocity(this.velocity);
                if (this.autoThrow) this.animateThrow(performance.now());
            } else {
                this.onDragComplete();
            }
        }

        _throwDeltaTime() {
            let t = performance.now();
            let dt = t - this.lastframe;
            this.lastframe = t;
            return dt
        }

        animateThrow(time) {
            if (this.velocity != null) {
                let dt = this._throwDeltaTime();
                // console.log("animateThrow", dt)
                let next = this.nextVelocity(this.velocity);
                let prevLength = Points.length(this.velocity);
                let nextLength = Points.length(next);
                if (nextLength > prevLength) {
                    let factor = nextLength / prevLength;
                    next = Points.multiplyScalar(next, 1 / factor);
                    console.log('Prevent acceleration', factor, this.velocity, next);
                }
                this.velocity = next;
                let d = Points.multiplyScalar(this.velocity, dt);
                this._move(d);

                this.onDragUpdate(d);
                if (dt == 0 || this.needsAnimation()) {
                    requestAnimationFrame(this.animateThrow.bind(this));
                    return
                } else {
                    if (this.isOutside()) {
                        requestAnimationFrame(this.animateThrow.bind(this));
                        return
                    }
                }
            }
            this.onDragComplete();
            if (this.onThrowFinished != null) {
                this.onThrowFinished();
            }
        }

        needsAnimation() {
            if (this.velocity == null) {
                return false
            }
            return Points.length(this.velocity) > 0.01
        }

        nextVelocity(velocity) {
            // Must be overwritten: computes the changed velocity. Implement
            // damping, collison detection, etc. here
            let next = Points.multiplyScalar(velocity, this.throwDamping);
            return {
                x: this.movableX ? next.x : 0,
                y: this.movableY ? next.y : 0
            }
        }

        _move(delta) {
            // Overwrite if necessary
        }

        onDragComplete() {
            // Overwrite if necessary
        }

        onDragUpdate(delta) {
            // Overwrite if necessary
        }
    }

    class AbstractScatter extends Throwable {
        constructor({
            minScale = 0.1,
            maxScale = 1.0,
            startScale = 1.0,
            autoBringToFront = true,
            autoThrow = true,
            translatable = true,
            scalable = true,
            rotatable = true,
            resizable = false,
            movableX = true,
            movableY = true,
            throwVisibility = 44,
            throwDamping = 0.95,
            overdoScaling = 1,
            mouseZoomFactor = 1.1,
            rotationDegrees = null,
            rotation = null,
            onTransform = null,
            interactive = true,
            onClose = null,
            onThrowFinished = null,
            scaleAutoClose = false,
            scaleCloseThreshold = 0.1,
            scaleCloseBuffer = 0.05,
            maxRotation = Angle.degree2radian(5),
            minInteractionDistance = 0,
            useLowPassFilter = false
        } = {}) {
            if (rotationDegrees != null && rotation != null) {
                throw new Error('Use rotationDegrees or rotation but not both')
            } else if (rotation != null) {
                rotationDegrees = Angle.radian2degree(rotation);
            } else if (rotationDegrees == null) {
                rotationDegrees = 0;
            }
            super({
                movableX,
                movableY,
                throwVisibility,
                throwDamping,
                autoThrow,
                onThrowFinished
            });

            /**
             * Closes the card when the minScale is reached and the
             * card is released. Card can be saved by scaling it up again.
             */
            this.scaleAutoClose = scaleAutoClose;
            this.scaleCloseThreshold = scaleCloseThreshold;
            this.scaleCloseBuffer = scaleCloseBuffer;
            this.scaleAutoCloseTimeout = null;

            this.interactive = interactive;
            this.startRotationDegrees = rotationDegrees;
            this.startScale = startScale; // Needed to reset object
            this.minScale = minScale;
            this.maxScale = maxScale;
            this.maxRotation = maxRotation;
            this.overdoScaling = overdoScaling;
            this.translatable = translatable;
            if (!translatable) {
                this.movableX = false;
                this.movableY = false;
            }
            this.scalable = scalable;
            this.rotatable = rotatable;
            this.resizable = resizable;
            this.mouseZoomFactor = mouseZoomFactor;
            this.autoBringToFront = autoBringToFront;
            this.useLowPassFilter = useLowPassFilter;
            this.minInteractionDistance = minInteractionDistance;
            if (useLowPassFilter) {
                this.rotateLPF = new LowPassFilter();
                this.zoomLPF = new LowPassFilter();
                this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
            }
            this.dragging = false;
            this.onTransform = onTransform != null ? [onTransform] : null;
            this.onClose = onClose != null ? [onClose] : null;
        }

        addCloseEventCallback(callback) {
            if (this.onClose == null) {
                this.onClose = [];
            }
            this.onClose.push(callback);
        }

        addTransformEventCallback(callback) {
            if (this.onTransform == null) {
                this.onTransform = [];
            }
            this.onTransform.push(callback);
        }

        startGesture(interaction) {
            this.bringToFront();
            this.killAnimation();
            this.observeVelocity();
            if (this.useLowPassFilter) {
                this.rotateLPF.clear();
                this.zoomLPF.clear();
                this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
            }
            return true
        }

        close() {
            if (this.onClose) {
                this.onClose.forEach(callback => callback(this));
            }
        }

        gesture(interaction) {
            let delta = interaction.delta();
            if (delta != null) {
                this.addVelocity(delta);
                let rotate = delta.rotate;
                let zoom = delta.zoom;
                if (this.maxRotation != null) {
                    if (Math.abs(rotate) > this.maxRotation) {
                        rotate = 0;
                    }
                }
                if (this.useLowPassFilter) {
                    rotate = this.rotateLPF.next(rotate);
                    zoom = this.zoomLPF.next(zoom);
                }
                if (delta.distance < this.minInteractionDistance) {
                    let ratio = delta.distance / this.minInteractionDistance;
                    rotate *= ratio;
                    let zoomDelta = zoom - 1;
                    zoomDelta *= ratio;
                    zoom = 1 + zoomDelta;
                }

                this.transform(delta, zoom, rotate, delta.about);
                if (zoom != 1) this.interactionAnchor = delta.about;
            }
        }

        get polygon() {
            let w2 = (this.width * this.scale) / 2;
            let h2 = (this.height * this.scale) / 2;
            let center = this.center;
            let polygon = new Polygon(center);
            polygon.addPoint({ x: -w2, y: -h2 });
            polygon.addPoint({ x: w2, y: -h2 });
            polygon.addPoint({ x: w2, y: h2 });
            polygon.addPoint({ x: -w2, y: h2 });
            polygon.rotate(this.rotation);
            return polygon
        }

        isOutside() {
            let stagePolygon = this.containerPolygon;
            if (stagePolygon == null) return false
            let polygon = this.polygon;
            if (polygon == null) return false
            let result = stagePolygon.intersectsWith(polygon);
            return result === false || result.overlap < this.throwVisibility
        }

        recenter() {
            // Return a small vector that guarantees that the scatter is moving
            // towards the center of the stage
            let center = this.center;
            let target = this.container.center;
            let delta = Points.subtract(target, center);
            return Points.normalize(delta)
        }

        nextVelocity(velocity) {
            return this.keepOnStage(velocity)
        }

        bouncing() {
            // Implements the bouncing behavior of the scatter. Moves the scatter
            // to the center of the stage if the scatter is outside the stage or
            // not within the limits of the throwVisibility.

            let stagePolygon = this.containerPolygon;
            let polygon = this.polygon;
            let result = stagePolygon.intersectsWith(polygon);
            if (result === false || result.overlap < this.throwVisibility) {
                let cv = this.recenter();
                let recentered = false;
                while (result === false || result.overlap < this.throwVisibility) {
                    polygon.center.x += cv.x;
                    polygon.center.y += cv.y;
                    this._move(cv);
                    result = stagePolygon.intersectsWith(polygon);
                    recentered = true;
                }
                return recentered
            }
            return false
        }

        keepOnStage(velocity, collision = 0.5) {
            let stagePolygon = this.containerPolygon;
            // UO: since keepOnStage is called in nextVelocity we need to
            // ensure a return value
            if (!stagePolygon) return { x: 0, y: 0 }
            let polygon = this.polygon;
            let bounced = this.bouncing();
            if (bounced) {
                let stage = this.containerBounds;
                let x = this.center.x;
                let y = this.center.y;
                let dx = this.movableX ? velocity.x : 0;
                let dy = this.movableY ? velocity.y : 0;
                let factor = this.throwDamping;
                // if (recentered) {
                if (x < 0) {
                    dx = -dx;
                    factor = collision;
                }
                if (x > stage.width) {
                    dx = -dx;
                    factor = collision;
                }
                if (y < 0) {
                    dy = -dy;
                    factor = collision;
                }
                if (y > stage.height) {
                    dy = -dy;
                    factor = collision;
                }
                // }
                return Points.multiplyScalar({ x: dx, y: dy }, factor)
            }
            return super.nextVelocity(velocity)
        }

        endGesture(interaction) {
            this.startThrow();
            this._checkAutoClose();
        }

        _checkAutoClose() {
            if (this.scaleAutoClose)
                if (
                    this.scale <
                    this.minScale + this.scaleCloseThreshold - this.scaleCloseBuffer
                ) {
                    this.zoom(this.minScale, {
                        animate: 0.2,
                        onComplete: this.close.bind(this)
                    });
                } else if (this.scale < this.minScale + this.scaleCloseThreshold) {
                    this.zoom(this.minScale + this.scaleCloseThreshold, {
                        animate: 0.4
                    });
                }
        }

        rotateDegrees(degrees, anchor) {
            let rad = Angle.degree2radian(degrees);
            this.rotate(rad, anchor);
        }

        rotate(rad, anchor) {
            this.transform({ x: 0, y: 0 }, 1.0, rad, anchor);
        }

        move(d, { animate = 0 } = {}) {
            if (this.translatable) {
                if (animate > 0) {
                    let startPos = this.position;
                    TweenLite.to(this, animate, {
                        x: '+=' + d.x,
                        y: '+=' + d.y,
                        /* scale: scale, uo: not defined, why was this here? */
                        onUpdate: e => {
                            let p = this.position;
                            let dx = p.x - startPos.x;
                            let dy = p.x - startPos.y;
                            this.onMoved(dx, dy);
                        }
                    });
                } else {
                    this._move(d);
                    this.onMoved(d.x, d.y);
                }
            }
        }

        moveTo(p, { animate = 0 } = {}) {
            let c = this.origin;
            let delta = Points.subtract(p, c);
            this.move(delta, { animate: animate });
        }

        centerAt(p, { animate = 0 } = {}) {
            let c = this.center;
            let delta = Points.subtract(p, c);
            this.move(delta, { animate: animate });
        }

        zoom(
            scale,
            {
                animate = 0,
                about = null,
                delay = 0,
                x = null,
                y = null,
                onComplete = null
            } = {}
        ) {
            let anchor = about || this.center;
            if (scale != this.scale) {
                if (animate > 0) {
                    TweenLite.to(this, animate, {
                        scale: scale,
                        delay: delay,
                        onComplete: onComplete,
                        onUpdate: this.onZoomed.bind(this)
                    });
                } else {
                    this.scale = scale;
                    this.onZoomed(anchor);
                }
            }
        }

        _move(delta) {
            this.x += this.movableX ? delta.x : 0;
            this.y += this.movableX ? delta.y : 0;
        }

        transform(translate, zoom, rotate, anchor) {
            let delta = {
                x: this.movableX ? translate.x : 0,
                y: this.movableY ? translate.y : 0
            };
            if (this.resizable) var vzoom = zoom;
            if (!this.translatable) delta = { x: 0, y: 0 };
            if (!this.rotatable) rotate = 0;
            if (!this.scalable) zoom = 1.0;
            if (zoom == 1.0 && rotate == 0) {
                this._move(delta);
                if (this.onTransform != null) {
                    let event = new ScatterEvent(this, {
                        translate: delta,
                        scale: this.scale,
                        rotate: 0,
                        about: anchor,
                        fast: false,
                        type: UPDATE
                    });
                    this.onTransform.forEach(function(f) {
                        f(event);
                    });
                }
                return
            }
            let origin = this.rotationOrigin;
            let beta = Points.angle(origin, anchor);
            let distance = Points.distance(origin, anchor);
            let { scale: newScale, zoom: thresholdedZoom } = this.calculateScale(
                zoom
            );

            let newOrigin = Points.arc(
                anchor,
                beta + rotate,
                distance * thresholdedZoom
            );
            let extra = Points.subtract(newOrigin, origin);
            let offset = Points.subtract(anchor, origin);
            this._move(offset);
            this.scale = newScale;
            this.rotation += rotate;
            offset = Points.negate(offset);
            offset = Points.add(offset, extra);
            offset = Points.add(offset, translate);
            this._move(offset);

            delta.x += extra.x;
            delta.y += extra.y;
            if (this.onTransform != null) {
                let event = new ScatterEvent(this, {
                    translate: delta,
                    scale: newScale,
                    rotate: rotate,
                    about: anchor
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
            if (this.resizable) {
                this.resizeAfterTransform(vzoom);
            }
        }

        /**
         * For a given zoom, a new scale is calculated, taking
         * min and max scale into account.
         *
         * @param {number} zoom - The zoom factor, to scale the object with.
         * @returns {object} - Returns an object containing the a value for a valid scale and the corrected zoom factor.
         */
        calculateScale(zoom) {
            let scale = this.scale * zoom;

            let minScale = this.minScale / this.overdoScaling;
            let maxScale = this.maxScale * this.overdoScaling;
            if (scale < minScale) {
                scale = minScale;
                zoom = scale / this.scale;
            }
            if (scale > maxScale) {
                scale = maxScale;
                zoom = scale / this.scale;
            }

            if (this.scaleAutoClose) this._updateTransparency();

            return { zoom, scale }
        }

        _updateTransparency() {
            if (this.scale < this.minScale + this.scaleCloseThreshold) {
                let transparency = this.calculateScaleTransparency();
                this.element.style.opacity = transparency;
            } else this.element.style.opacity = 1;
        }

        calculateScaleTransparency() {
            let transparency =
                (this.scale - this.minScale) / this.scaleCloseThreshold;
            transparency =
                transparency > 1 ? 1 : transparency < 0 ? 0 : transparency;
            return transparency
        }

        resizeAfterTransform(zoom) {
            // Overwrite this in subclasses.
        }

        validScale(scale) {
            scale = Math.max(scale, this.minScale);
            scale = Math.min(scale, this.maxScale);
            return scale
        }

        animateZoomBounce(dt = 1) {
            if (this.zoomAnchor != null) {
                let zoom = 1;
                let amount = Math.min(0.01, (0.3 * dt) / 100000.0);
                if (this.scale < this.minScale) zoom = 1 + amount;
                if (this.scale > this.maxScale) zoom = 1 - amount;
                if (zoom != 1) {
                    this.transform({ x: 0, y: 0 }, zoom, 0, this.zoomAnchor);
                    requestAnimationFrame(dt => {
                        this.animateZoomBounce(dt);
                    });
                    return
                }
                this.zoomAnchor = null;
            }
        }

        checkScaling(about, delay = 0) {
            this.zoomAnchor = about;
            clearTimeout(this.animateZoomBounce.bind(this));
            setTimeout(this.animateZoomBounce.bind(this), delay);
        }

        onMouseWheel(event) {
            if (event.claimedByScatter) {
                if (event.claimedByScatter != this) return
            }
            this.killAnimation();
            this.targetScale = null;
            let direction = event.detail < 0 || event.wheelDelta > 0;
            let globalPoint = { x: event.clientX, y: event.clientY };
            let centerPoint = this.mapPositionToContainerPoint(globalPoint);
            if (event.shiftKey) {
                let degrees = direction ? 5 : -5;
                let rad = Angle.degree2radian(degrees);
                return this.transform({ x: 0, y: 0 }, 1.0, rad, centerPoint)
            }
            const zoomFactor = this.mouseZoomFactor;
            let zoom = direction ? zoomFactor : 1 / zoomFactor;
            this.transform({ x: 0, y: 0 }, zoom, 0, centerPoint);
            this.checkScaling(centerPoint, 200);

            if (this.scaleAutoClose) {
                if (this.scale <= this.minScale + this.scaleCloseThreshold) {
                    if (this.scaleAutoCloseTimeout)
                        clearTimeout(this.scaleAutoCloseTimeout);
                    this.scaleAutoCloseTimeout = setTimeout(() => {
                        this._checkAutoClose();
                    }, 600);
                }
                this._updateTransparency();
            }
        }

        onStart(event, interaction) {
            if (this.startGesture(interaction)) {
                this.dragging = true;
                this.interactionAnchor = null;
            }
            if (this.onTransform != null) {
                let event = new ScatterEvent(this, {
                    translate: { x: 0, y: 0 },
                    scale: this.scale,
                    rotate: 0,
                    about: null,
                    fast: false,
                    type: START
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
        }

        onMove(event, interaction) {
            /** As long as mouseout && mouseleave interrupt we cannot be sure that
             * dragging remains correct.
             */
            if (this.dragging) {
                this.gesture(interaction);
            }
        }

        onEnd(event, interaction) {
            //console.log("Scatter.onEnd", this.dragging)
            if (interaction.isFinished()) {
                this.endGesture(interaction);
                this.dragging = false;
                for (let key of interaction.ended.keys()) {
                    if (interaction.isTap(key)) {
                        let point = interaction.ended.get(key);
                        this.onTap(event, interaction, point);
                    }
                }
                if (this.onTransform != null) {
                    let event = new ScatterEvent(this, {
                        translate: { x: 0, y: 0 },
                        scale: this.scale,
                        rotate: 0,
                        about: null,
                        fast: false,
                        type: END
                    });
                    this.onTransform.forEach(function(f) {
                        f(event);
                    });
                }
            }
            let about = this.interactionAnchor;
            if (about != null) {
                this.checkScaling(about, 100);
            }
        }

        onTap(event, interaction, point) {}

        onDragUpdate(delta) {
            if (this.onTransform != null) {
                let event = new ScatterEvent(this, {
                    fast: true,
                    translate: delta,
                    scale: this.scale,
                    about: this.currentAbout,
                    type: null
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
        }

        onDragComplete() {
            if (this.onTransform) {
                let event = new ScatterEvent(this, {
                    scale: this.scale,
                    about: this.currentAbout,
                    fast: false,
                    type: null
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
        }

        onMoved(dx, dy, about) {
            if (this.onTransform != null) {
                let event = new ScatterEvent(this, {
                    translate: { x: dx, y: dy },
                    about: about,
                    fast: true,
                    type: null
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
        }

        onResizing() {
            if (this.onTransform != null) {
                let event = new ScatterEvent(this, {
                    scale: this.scale,
                    fast: false,
                    type: null
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
        }

        onZoomed(about) {
            if (this.scaleAutoClose) this._updateTransparency();

            if (this.onTransform != null) {
                let event = new ScatterEvent(this, {
                    scale: this.scale,
                    about: about,
                    fast: false,
                    type: null
                });
                this.onTransform.forEach(function(f) {
                    f(event);
                });
            }
        }
    }

    class DOMScatter extends AbstractScatter {
        constructor(
            element,
            container,
            {
                startScale = 1.0,
                minScale = 0.1,
                maxScale = 1.0,
                overdoScaling = 1.5,
                autoBringToFront = true,
                translatable = true,
                scalable = true,
                rotatable = true,
                movableX = true,
                movableY = true,
                rotationDegrees = null,
                rotation = null,
                onTransform = null,
                transformOrigin = 'center center',
                // extras which are in part needed
                x = 0,
                y = 0,
                width = null, // required
                height = null, // required
                resizable = false,
                tapDelegate = null,
                triggerSVGClicks = false,
                allowClickDistance = 44,
                verbose = true,
                onResize = null,
                touchAction = 'none',
                throwVisibility = 44,
                throwDamping = 0.95,
                autoThrow = true,
                scaleAutoClose = false,
                onClose = null,
                scaleCloseThreshold = 0.1,
                scaleCloseBuffer = 0.05,
                useLowPassFilter = false,
                maxRotation = Angle.degree2radian(15),
                minInteractionDistance = 200
            } = {}
        ) {
            super({
                minScale,
                maxScale,
                startScale,
                overdoScaling,
                autoBringToFront,
                translatable,
                scalable,
                rotatable,
                movableX,
                movableY,
                resizable,
                rotationDegrees,
                rotation,
                onTransform,
                throwVisibility,
                throwDamping,
                autoThrow,
                scaleAutoClose,
                scaleCloseThreshold,
                scaleCloseBuffer,
                onClose,
                useLowPassFilter,
                maxRotation,
                minInteractionDistance
            });
            if (container == null || width == null || height == null) {
                throw new Error('Invalid value: null')
            }
            element.scatter = this;
            this.element = element;
            this.x = x;
            this.y = y;
            this.oldX = 0;
            this.oldY = 0;
            this.meanX = x;
            this.meanY = y;
            this.width = width;
            this.height = height;
            this.throwVisibility = Math.min(width, height, throwVisibility);
            this.container = container;
            this.tapDelegate = tapDelegate;
            this.scale = startScale;
            this.rotationDegrees = this.startRotationDegrees;
            this.transformOrigin = transformOrigin;
            this.initialValues = {
                x: x,
                y: y,
                width: width,
                height: height,
                scale: startScale,
                rotation: this.startRotationDegrees,
                transformOrigin: transformOrigin
            };
            this.tapNodes = new Map();

            // For tweenlite we need initial values in _gsTransform
            TweenLite.set(element, this.initialValues);
            this.onResize = onResize;
            this.verbose = verbose;
            if (touchAction !== null) {
                Elements$1.setStyle(element, { touchAction });
            }
            this.resizeButton = null;
            if (resizable) {
                let button = document.createElement('div');
                button.style.position = 'absolute';
                button.style.right = '0px';
                button.style.bottom = '0px';
                button.style.width = '50px';
                button.style.height = '50px';
                button.className = 'interactiveElement';
                this.element.appendChild(button);

                button.addEventListener('pointerdown', e => {
                    this.startResize(e);
                });

                button.addEventListener('pointermove', e => {
                    this.resize(e);
                });

                button.addEventListener('pointerup', e => {
                    this.stopResize(e);
                });
                this.resizeButton = button;
            }
            if (tapDelegate) {
                tapDelegate.handleClicks();
            }
            container.add(this);
        }

        /** Returns geometry data as object. **/
        getState() {
            return {
                scale: this.scale,
                x: this.x,
                y: this.y,
                rotation: this.rotation
            }
        }

        close() {
            super.close();
            let parent = this.element.parentNode;
            if (parent) parent.removeChild(this.element);
        }

        get rotationOrigin() {
            return this.center
        }

        get x() {
            return this._x
        }

        get y() {
            return this._y
        }

        set x(value) {
            this._x = value;
            TweenLite.set(this.element, { x: value });
        }

        set y(value) {
            this._y = value;
            TweenLite.set(this.element, { y: value });
        }

        get position() {
            let transform = this.element._gsTransform;
            let x = transform.x;
            let y = transform.y;
            return { x, y }
        }

        get origin() {
            let p = this.fromNodeToPage(0, 0);
            return Points.fromPageToNode(this.container.element, p)
        }

        get bounds() {
            let stage = this.container.element.getBoundingClientRect();
            let rect = this.element.getBoundingClientRect();
            return {
                top: rect.top - stage.top,
                left: rect.left - stage.left,
                width: rect.width,
                height: rect.height
            }
        }

        get center() {
            let r = this.bounds;
            let w2 = r.width / 2;
            let h2 = r.height / 2;
            //   if (this.resizable) {
            //             w2 *= this.scale
            //             h2 *= this.scale
            //         }
            var x = r.left + w2;
            var y = r.top + h2;
            return { x, y }
        }

        set rotation(radians) {
            let rad = radians; // Angle.normalize(radians)
            let degrees = Angle.radian2degree(rad);
            TweenLite.set(this.element, { rotation: degrees });
            this._rotation = rad;
        }

        set rotationDegrees(degrees) {
            let deg = degrees; // Angle.normalizeDegree(degrees)
            TweenLite.set(this.element, { rotation: deg });
            this._rotation = Angle.degree2radian(deg);
        }

        get rotation() {
            return this._rotation
        }

        get rotationDegrees() {
            return this._rotation
        }

        set scale(scale) {
            TweenLite.set(this.element, {
                scale: scale,
                transformOrigin: this.transformOrigin
            });
            this._scale = scale;
        }

        get scale() {
            return this._scale
        }

        get containerBounds() {
            return this.container.bounds
        }

        get containerPolygon() {
            return this.container.polygon
        }

        mapPositionToContainerPoint(point) {
            return this.container.mapPositionToPoint(point)
        }

        capture(event) {
            return true
        }

        reset() {
            TweenLite.set(this.element, this.initialValues);
        }

        hide() {
            TweenLite.to(this.element, 0.1, {
                display: 'none',
                onComplete: e => {
                    this.element.parentNode.removeChild(this.element);
                }
            });
        }

        show() {
            TweenLite.set(this.element, { display: 'block' });
        }

        showAt(p, rotationDegrees) {
            TweenLite.set(this.element, {
                display: 'block',
                x: p.x,
                y: p.y,
                rotation: rotationDegrees,
                transformOrigin: this.transformOrigin
            });
        }

        bringToFront() {
            // this.element.parentNode.appendChild(this.element)
            // uo: On Chome and Electon appendChild leads to flicker
            TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ });
        }

        onTap(event, interaction, point) {
            if (this.tapDelegate) {
                Events$1.stop(event);
                this.tapDelegate.tap(event, 'scatter');
            }
        }

        isDescendant(parent, child) {
            let node = child.parentNode;
            while (node != null) {
                if (node == parent) {
                    return true
                }
                node = node.parentNode;
            }
            return false
        }

        fromPageToNode(x, y) {
            return Points.fromPageToNode(this.element, { x, y })
        }

        fromNodeToPage(x, y) {
            return Points.fromNodeToPage(this.element, { x, y })
        }

        _move(delta) {
            // UO: We need to keep TweenLite's _gsTransform and the private
            // _x and _y attributes aligned
            let x = this.element._gsTransform.x;
            let y = this.element._gsTransform.y;
            if (this.movableX) {
                x += delta.x;
            }
            if (this.movableY) {
                y += delta.y;
            }
            this._x = x;
            this._y = y;
            TweenLite.set(this.element, { x: x, y: y });
        }

        resizeAfterTransform(zoom) {
            if (this.onResize) {
                let w = this.width * this.scale;
                let h = this.height * this.scale;
                let event = new ResizeEvent(this, { width: w, height: h });
                this.onResize(event);
            }
        }

        startResize(e) {
            e.preventDefault();
            let event = new CustomEvent('resizeStarted');

            let oldPostition = {
                x: this.element.getBoundingClientRect().left,
                y: this.element.getBoundingClientRect().top
            };
            this.bringToFront();

            this.element.style.transformOrigin = '0% 0%';

            let newPostition = {
                x: this.element.getBoundingClientRect().left,
                y: this.element.getBoundingClientRect().top
            };

            let offset = Points.subtract(oldPostition, newPostition);

            this.oldX = e.clientX;
            this.oldY = e.clientY;

            e.target.setAttribute('resizing', 'true');
            e.target.setPointerCapture(e.pointerId);

            TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } });
            TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } });

            this.element.dispatchEvent(event);
        }

        resize(e) {
            e.preventDefault();

            let rotation = Angle.radian2degree(this.rotation);
            rotation = (rotation + 360) % 360;
            let event = new CustomEvent('resized');
            if (e.target.getAttribute('resizing') == 'true') {
                let deltaX = e.clientX - this.oldX;
                let deltaY = e.clientY - this.oldY;

                let r = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
                let phi = Angle.radian2degree(Math.atan2(deltaX, deltaY));

                phi = (phi + 630) % 360;
                let rot = (rotation + 90 + 630) % 360;

                let diffAngle = (0 + rot + 360) % 360;
                let phiCorrected = (phi + diffAngle + 360) % 360;

                let resizeW = r * Math.cos(Angle.degree2radian(phiCorrected));
                let resizeH = -r * Math.sin(Angle.degree2radian(phiCorrected));

                if (
                    (this.element.offsetWidth + resizeW) / this.scale >
                        (this.width * 0.5) / this.scale &&
                    (this.element.offsetHeight + resizeH) / this.scale >
                        (this.height * 0.3) / this.scale
                )
                    TweenLite.to(this.element, 0, {
                        width: this.element.offsetWidth + resizeW / this.scale,
                        height: this.element.offsetHeight + resizeH / this.scale
                    });

                this.oldX = e.clientX;
                this.oldY = e.clientY;
                this.onResizing();

                this.element.dispatchEvent(event);
            }
        }

        stopResize(e) {
            e.preventDefault();

            let event = new CustomEvent('resizeEnded');
            let oldPostition = {
                x: this.element.getBoundingClientRect().left,
                y: this.element.getBoundingClientRect().top
            };
            this.element.style.transformOrigin = '50% 50%';
            let newPostition = {
                x: this.element.getBoundingClientRect().left,
                y: this.element.getBoundingClientRect().top
            };
            let offset = Points.subtract(oldPostition, newPostition);

            TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } });
            TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } });

            e.target.setAttribute('resizing', 'false');

            this.element.dispatchEvent(event);
        }
    }

    DOMScatter.zIndex = 1000;

    class CardLoader {
        constructor(
            src,
            {
                x = 0,
                y = 0,
                width = 1000,
                height = 800,
                maxWidth = null,
                maxHeight = null,
                scale = 1,
                minScale = 0.5,
                maxScale = 1.5,
                rotation = 0
            } = {}
        ) {
            this.src = src;
            this.x = x;
            this.y = y;
            this.scale = scale;
            this.rotation = 0;
            this.maxScale = maxScale;
            this.minScale = minScale;
            this.wantedWidth = width;
            this.wantedHeight = height;
            this.maxWidth = maxWidth != null ? maxWidth : window.innerWidth;
            this.maxHeight = maxHeight != null ? maxHeight : window.innerHeight;
            this.addedNode = null;
            console.log({
                width,
                height,
                maxWidth,
                maxHeight
            });
        }

        unload() {
            if (this.addedNode) {
                this.addedNode.remove();
                this.addedNode = null;
            }
        }
    }

    class DOMFlip {
        constructor(
            domScatterContainer,
            flipTemplate,
            frontLoader,
            backLoader,
            {
                closeOnMinScale = false,
                flipDuration = 1,
                fadeDuration = 0.2,
                overdoScaling = 1,
                autoLoad = false,
                center = null,
                preloadBack = false,
                translatable = true,
                scalable = true,
                rotatable = true,
                tapDelegateFactory = null,
                onFront = null,
                onBack = null,
                onClose = null,
                onUpdate = null,
                onRemoved = null,
                onLoaded = null
            } = {}
        ) {
            this.domScatterContainer = domScatterContainer;
            this.id = getId();
            this.flipDuration = flipDuration;
            this.fadeDuration = fadeDuration;
            this.closeOnMinScale = closeOnMinScale;
            this.flipTemplate = flipTemplate;
            this.frontLoader = frontLoader;
            this.backLoader = backLoader;
            this.translatable = translatable;
            this.scalable = scalable;
            this.rotatable = rotatable;
            this.tapDelegateFactory = tapDelegateFactory;
            this.onFrontFlipped = onFront;
            this.onBackFlipped = onBack;
            this.onClose = onClose;
            this.onRemoved = onRemoved;
            this.onUpdate = onUpdate;
            this.onLoaded = onLoaded;
            this.center = center;
            this.preloadBack = preloadBack;
            this.overdoScaling = overdoScaling;
            if (autoLoad) {
                this.load();
            }
        }

        load() {
            return new Promise((resolve, reject) => {
                let t = this.flipTemplate;
                let dom = this.domScatterContainer.element;
                let wrapper = t.content.querySelector('.flipWrapper');
                wrapper.id = this.id;
                let clone = document.importNode(t.content, true);
                dom.appendChild(clone);
                // We cannot use the document fragment itself because it
                // is not part of the main dom tree. After the appendChild
                // call we can access the new dom element by id
                this.cardWrapper = dom.querySelector('#' + this.id);
                let front = this.cardWrapper.querySelector('.front');
                this.frontLoader.load(front).then(loader => {
                    this.frontLoaded(loader).then(obj => {
                        if (this.onLoaded) this.onLoaded();
                        resolve(this);
                    });
                });
            })
        }

        frontLoaded(loader) {
            return new Promise((resolve, reject) => {
                let scatter = new DOMScatter(
                    this.cardWrapper,
                    this.domScatterContainer,
                    {
                        x: loader.x,
                        y: loader.y,
                        startScale: loader.scale,
                        scale: loader.scale,
                        maxScale: loader.maxScale,
                        minScale: loader.minScale,
                        width: loader.wantedWidth,
                        height: loader.wantedHeight,
                        rotation: loader.rotation,
                        translatable: this.translatable,
                        scalable: this.scalable,
                        rotatable: this.rotatable,
                        overdoScaling: this.overdoScaling,
                        tapDelegate: this.tapDelegateFactory
                            ? this.tapDelegateFactory(this.cardWrapper)
                            : null
                    }
                );

                if (this.center) {
                    scatter.centerAt(this.center);
                }

                if (this.closeOnMinScale) {
                    const removeOnMinScale = function() {
                        if (scatter.scale <= scatter.minScale) {
                            this.flippable.close();

                            // 'Disable' overdoscaling to avoid weird jumps on close.
                            scatter.minScale /= scatter.overdoScaling;
                            scatter.overdoScaling = 1;

                            //Remove callback
                            if (scatter.onTransform) {
                                let callbackIdx = scatter.onTransform.indexOf(
                                    removeOnMinScale
                                );
                                scatter.onTransform.splice(callbackIdx, 1);
                            }
                        }
                    }.bind(this);
                    scatter.addTransformEventCallback(removeOnMinScale);
                }

                let flippable = new DOMFlippable(this.cardWrapper, scatter, this);
                let back = this.cardWrapper.querySelector('.back');

                if (this.preloadBack) {
                    this.backLoader.load(back).then(loader => {
                        this.setupFlippable(flippable, loader);
                    });
                }
                this.flippable = flippable;
                resolve(this);
            })
        }

        centerAt(p) {
            this.center = p;
            this.flippable.centerAt(p);
        }

        zoom(scale) {
            this.flippable.zoom(scale);
        }

        setupFlippable(flippable, loader) {
            console.log('setupFlippable', loader.wantedWidth);
            flippable.wantedWidth = loader.wantedWidth;
            flippable.wantedHeight = loader.wantedHeight;
            flippable.wantedScale = loader.scale;
            flippable.minScale = loader.minScale;
            flippable.maxScale = loader.maxScale;
            flippable.scaleButtons();
        }

        start({ targetCenter = null } = {}) {
            console.log('DOMFlip.start', targetCenter);
            if (this.preloadBack) {
                this.flippable.start({ duration: this.flipDuration, targetCenter });
            } else {
                let back = this.cardWrapper.querySelector('.back');
                let flippable = this.flippable;
                this.backLoader.load(back).then(loader => {
                    this.setupFlippable(flippable, loader);
                    flippable.start({ duration: this.flipDuration, targetCenter });
                });
            }
        }

        fadeOutAndRemove() {
            TweenLite.to(this.cardWrapper, this.fadeDuration, {
                opacity: 0,
                onComplete: () => {
                    this.cardWrapper.remove();
                }
            });
        }

        closed() {
            this.unload();
        }

        unload() {
            if (!this.preloadBack) {
                this.backLoader.unload();
            }
        }
    }

    class DOMFlippable {
        constructor(element, scatter, flip) {
            // Set log to console.log or a custom log function
            // define data structures to store our touchpoints in

            this.element = element;
            this.flip = flip;
            this.card = element.querySelector('.flipCard');
            this.front = element.querySelector('.front');
            this.back = element.querySelector('.back');
            this.flipped = false;
            this.scatter = scatter;
            this.onFrontFlipped = flip.onFrontFlipped;
            this.onBackFlipped = flip.onBackFlipped;
            this.onClose = flip.onClose;
            this.onRemoved = flip.onRemoved;
            this.onUpdate = flip.onUpdate;

            this.wantedWidth = scatter.width;
            this.wantedHeight = scatter.height;
            this.wantedScale = scatter.scale;
            this.minScale = scatter.minScale;
            this.maxScale = scatter.maxScale;

            this.flipDuration = flip.flipDuration;
            this.fadeDuration = flip.fadeDuration;
            scatter.addTransformEventCallback(this.scatterTransformed.bind(this));

            TweenLite.set(this.element, { perspective: 5000 });
            TweenLite.set(this.card, { transformStyle: 'preserve-3d' });
            TweenLite.set(this.back, { rotationY: -180 });
            TweenLite.set([this.back, this.front], {
                backfaceVisibility: 'hidden',
                perspective: 5000
            });
            TweenLite.set(this.front, { visibility: 'visible' });
            this.infoBtn = element.querySelector('.infoBtn');
            this.backBtn = element.querySelector('.backBtn');
            this.closeBtn = element.querySelector('.closeBtn');
            /* Buttons are not guaranteed to exist. */

            if (this.infoBtn) {
                scatter.addTapListener(this.infoBtn, event => {
                    this.flip.start();
                });
                this.enable(this.infoBtn);
            }
            if (this.backBtn) {
                scatter.addTapListener(this.backBtn, event => {
                    this.start();
                });
            }
            if (this.closeBtn) {
                scatter.addTapListener(this.closeBtn, event => {
                    this.close();
                });
                this.enable(this.closeBtn);
            }
            this.scaleButtons();
            this.bringToFront();
        }

        close() {
            this.disable(this.infoBtn);
            this.disable(this.closeBtn);
            if (this.onClose) {
                this.onClose(this);
                this.flip.closed();
            } else {
                this.scatter.zoom(0.1, {
                    animate: this.fadeDuration,
                    onComplete: () => {
                        this.element.remove();
                        this.flip.closed();
                        if (this.onRemoved) {
                            this.onRemoved.call(this);
                        }
                    }
                });
            }
        }

        showFront() {
            TweenLite.set(this.front, { visibility: 'visible' });
        }

        centerAt(p) {
            this.scatter.centerAt(p);
        }

        zoom(scale) {
            this.scatter.zoom(scale);
        }

        get buttonScale() {
            let iscale = 1.0;

            if (this.scatter != null) {
                let scale = this.scatter.scale || 1;
                iscale = 1.0 / scale;
            }
            return iscale
        }

        scaleButtons() {
            TweenLite.set([this.infoBtn, this.backBtn, this.closeBtn], {
                scale: this.buttonScale
            });
        }

        bringToFront() {
            this.scatter.bringToFront();
            TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ });
        }

        clickInfo() {
            this.bringToFront();
            console.log('clickInfo');
            this.infoBtn.click();
        }

        scatterTransformed(event) {
            this.scaleButtons();
        }

        targetRotation(alpha) {
            let ortho = 90;
            let rest = alpha % ortho;
            let delta = 0.0;
            if (rest > ortho / 2.0) {
                delta = ortho - rest;
            } else {
                delta = -rest;
            }
            return delta
        }

        infoValues(info) {
            let startX = this.element._gsTransform.x;
            let startY = this.element._gsTransform.y;
            let startAngle = this.element._gsTransform.rotation;
            let startScale = this.element._gsTransform.scaleX;
            let w = this.element.style.width;
            let h = this.element.style.height;
            console.log(info, startX, startY, startAngle, startScale, w, h);
        }

        show(element, duration = 0, alpha = 1) {
            if (element) {
                TweenLite.to(element, duration, { autoAlpha: alpha }); // visibility: 'visible', display: 'initial'})
            }
        }

        hide(element, duration = 0, alpha = 0) {
            if (element) {
                TweenLite.to(element, duration, { autoAlpha: alpha }); // {visibility: 'hidden', display: 'none'})
            }
        }

        enable(button) {
            this.show(button, this.fadeDuration);
            if (button) {
                TweenLite.set(button, { pointerEvents: 'auto' });
            }
        }

        disable(button) {
            this.hide(button, this.fadeDuration);
            if (button) {
                TweenLite.set(button, { pointerEvents: 'none' });
            }
        }

        start({ targetCenter = null } = {}) {
            this.bringToFront();
            if (!this.flipped) {
                this.startX = this.element._gsTransform.x;
                this.startY = this.element._gsTransform.y;
                this.startAngle = this.element._gsTransform.rotation;
                this.startScale = this.element._gsTransform.scaleX;
                this.startWidth = this.element.style.width;
                this.startHeight = this.element.style.height;
                this.scatterStartWidth = this.scatter.width;
                this.scatterStartHeight = this.scatter.height;
                this.show(this.back);
                this.disable(this.infoBtn);
                this.disable(this.closeBtn);
            } else {
                this.show(this.front, this.fadeDuration);
                this.disable(this.backBtn);
            }
            let { scalable, translatable, rotatable } = this.scatter;
            this.saved = { scalable, translatable, rotatable };
            this.scatter.scalable = false;
            this.scatter.translatable = false;
            this.scatter.rotatable = false;
            this.scatter.killAnimation();

            this.flipped = !this.flipped;
            let targetY = this.flipped ? 180 : 0;
            let targetZ = this.flipped
                ? this.startAngle + this.targetRotation(this.startAngle)
                : this.startAngle;
            let targetScale = this.flipped ? this.wantedScale : this.startScale;
            let w = this.flipped ? this.wantedWidth : this.startWidth;
            let h = this.flipped ? this.wantedHeight : this.startHeight;
            let dw = this.wantedWidth - this.scatter.width;
            let dh = this.wantedHeight - this.scatter.height;
            let tc = targetCenter;
            let xx = tc != null ? tc.x - w / 2 : this.startX - dw / 2;
            let yy = tc != null ? tc.y - h / 2 : this.startY - dh / 2;
            let x = this.flipped ? xx : this.startX;
            let y = this.flipped ? yy : this.startY;

            let onUpdate = this.onUpdate !== null ? () => this.onUpdate(this) : null;
            console.log('start', this.flipDuration);
            TweenLite.to(this.card, this.flipDuration, {
                rotationY: targetY,
                ease: Power1.easeOut,
                transformOrigin: '50% 50%',
                onUpdate,
                onComplete: e => {
                    console.log('start end', this.flipDuration);
                    if (this.flipped) {
                        //this.hide(this.front)
                        this.enable(this.backBtn);
                        this.show(this.backBtn);

                        if (this.onFrontFlipped) {
                            this.onFrontFlipped(this);
                        }
                    } else {
                        if (this.onBackFlipped == null) {
                            this.enable(this.infoBtn, this.fadeDuration);
                            this.enable(this.closeBtn, this.fadeDuration);
                        } else {
                            this.onBackFlipped(this);
                        }
                        this.flip.unload();
                    }
                    this.scatter.scale = targetScale;
                    this.scaleButtons();
                    this.scatter.rotationDegrees = targetZ;
                    this.scatter.width = this.flipped ? w : this.scatterStartWidth;
                    this.scatter.height = this.flipped ? h : this.scatterStartHeight;

                    let { scalable, translatable, rotatable } = this.saved;
                    this.scatter.scalable = scalable;
                    this.scatter.translatable = translatable;
                    this.scatter.rotatable = rotatable;
                },
                force3D: true
            });

            console.log('start 2', this.wantedWidth, this.startWidth, { w, h });
            // See https://greensock.com/forums/topic/7997-rotate-the-shortest-way/
            TweenLite.to(this.element, this.flipDuration / 2, {
                scale: targetScale,
                ease: Power1.easeOut,
                rotationZ: targetZ + '_short',
                transformOrigin: '50% 50%',
                width: w,
                height: h,
                x: x,
                y: y,
                onComplete: e => {
                    if (this.flipped) {
                        this.hide(this.front);
                        // this.hide(this.infoBtn)
                    } else {
                        this.hide(this.back);
                        // this.show(this.infoBtn)
                    }
                }
            });
        }
    }

    /* ES Lint  */
    /* globals PIXI, console*/

    const registeredTiles = new Map();
    /** Implements a baseTexture cache. The last textures are kept for reuse  */
    let keepTextures = 0;
    const keptTextures = [];
    const lateTextures = new Map();
    let lastSweepTime = 0;
    let sweepInterval = 2000.0;

    /** The current Tile implementation simply uses PIXI.Sprites.
     *
     * BTW: PIXI.extras.TilingSprite is not appropriate. It should be used for
     * repeating patterns.
     **/
    class Tile extends PIXI.Sprite {
        constructor(texture, url) {
            super(texture);
            this.url = url;
            this.register(url);
        }

        /**
         * Static method to enable keeping of base textures
         *
         * @static
         * @param {*} value
         * @memberof Tile
         */
        static enableKeepTextures(value = 1000) {
            keepTextures = value;
        }

        /**
         * Returns true iff the url is pending
         *
         * @static
         * @param {*} url
         * @returns
         * @memberof Tile
         */
        /*static isPending(url) {
            return pendingTiles.has(url) && pendingTiles.get(url) > 0
        } */

        static isObsolete(url) {
            if (registeredTiles.has(url) && registeredTiles.get(url) > 0) {
                return false
            }
            return true
        }

        /**
         * Loads a tile from image using the PIXI.Texture.fromImage method.
         *
         * @static
         * @param {*} imageId
         * @param {*} crossorigin
         * @param {*} scaleMode
         * @returns
         * @memberof Tile
         */
        static fromImage(imageId, crossorigin, scaleMode) {
            return new Tile(PIXI.Texture.fromImage(imageId, crossorigin, scaleMode), imageId)
        }

        /**
         * Registers the tile in the global reference counter for textures
         *
         * @param {*} url
         * @param {boolean} [debug=false]
         * @memberof Tile
         */
        register(url, debug = false) {
            //Tile.unschedule(url)
            if (registeredTiles.has(url)) {
                let tiles = registeredTiles.get(url);
                tiles.add(this);
                if (debug) console.log("Tile.register", url, tiles.size);
            }
            else {
                registeredTiles.set(url, new Set([this]));
                if (debug) console.log("Tile.register", url, 1);
            }
        }

        /**
         * Unregisters the rile in the global reference counter for textures
         *
         * @returns {number} The number of how often a texture is used.
         * @memberof Tile
         */
        unregister() {
            let tiles = registeredTiles.get(this.url);
            tiles.delete(this);
            if (tiles.size == 0) {
                registeredTiles.delete(this.url);
                return 0
            }
            return tiles.size
        }

        /**
         * Destroys this sprite and optionally its texture and children
         *
         * @param {*} options  Part of the PIXI API, but ignored in the implementation
         * @memberof Tile
         */
        destroy(options, debug = false) {
            let count = this.unregister();

            if (keepTextures > 0) {
                keptTextures.push({ url: this.url, texture: this.texture });

                let opts = { children: true, texture: false, baseTexture: false };
                if (debug) console.log("Tile.destroy", registeredTiles.size, opts);
                super.destroy(opts);

                while (keptTextures.length > keepTextures) {
                    let { url, texture } = keptTextures.shift();
                    if (Tile.isObsolete(url)) {
                        texture.destroy(true); // Destroy base as well
                        if (debug) console.log("Destroying texture and baseTexture", url);
                    }
                }
            }
            else {
                // No longer registered and not pending
                if (count <= 0) { // && !Tile.isPending(this.url)
                    let opts = { children: true, texture: true, baseTexture: true };
                    super.destroy(opts);
                    if (debug) console.log("Tile.destroy", registeredTiles.size, opts);
                }
                else {
                    let opts = { children: true, texture: false, baseTexture: false };
                    if (debug) console.log("Tile.destroy", registeredTiles.size, opts);
                    super.destroy(opts);
                }
                if (this.parent != null) {
                    // UO: Emit warning and remove
                    console.warn("Destroying tile with parent. Hiding instead");
                    this.visible = false;
                }
            }
        }

        /**
         * Returns an available texture that can be reused
         *
         * @param {*} url
         * @returns
         * @memberof Tile
         */
        static textureAvailable(url) {
            if (registeredTiles.has(url)) {
                let tiles = registeredTiles.get(url);
                for (let tile of tiles.values()) {
                    //console.log("Reusing cached texture", tile.parent)
                    return tile.texture
                }
            }
            return null
        }

        /**
         * Specialized renderWebGL to avoid freezing system
         *
         * @param {*} renderer
         * @memberof Tile
         */
        renderWebGL(renderer) {
            try {
                super.renderWebGL(renderer);
            }
            catch (e) {
                // We want persistent logging here
                Logging.error("Error in Tile.renderWebGL: " + e.message);
            }
        }

        /**
         * Removes lately arrived texture if they have not been touched in the meanwhile.
         *
         * @static
         * @memberof Tile
         */
        static sweepLateTextures() {
            let now = performance.now();
            if (now > lastSweepTime + sweepInterval) {
                lastSweepTime = now;
                let count = 0;
                for (let [url, texture] of lateTextures.entries()) {
                    if (texture) {
                        let base = texture.baseTexture;
                        if (base != null && base.touched == 0) {
                            texture.destroy(true);
                            //console.info("Sweeping ", url)
                            count += 1;
                            lateTextures.delete(url);
                        }
                    }
                }
                if (count > 0)
                    console.log("Sweeping textures", count);
            }
        }

        /**
         * Texture received too late. We do not need it.
         * @param {*} url 
         * @param {*} texture 
         */
        static lateTexture(url, texture) {
            lateTextures.set(url, texture);
            //console.warn("Tile.lateTexture")
            // We cannot destroy the texture since we got errors in t.renderWebGL.
            // Perhaps we can destroy unsed textures later on
            Tile.sweepLateTextures();
        }

        static printInfos() {
            let references = new Map();
            let multiples = 0;
            for (let [url, tiles] of registeredTiles.entries()) {
                let count = tiles.size;
                references.set(url, count);
                if (count > 1) {
                    multiples += 1;
                }
            }
            console.log({ multiples, references });
        }
    }

    /**
     * A Tile Loader component that can be plugged into a Tiles Layer.
     */
    class TileLoader {
        constructor(tiles) {
            this.debug = false;
            this.tiles = tiles;
            this.setup();
        }

        /** Setup collections and instance vars. */
        setup() {
            this.map = new Map(); // Map {url : [ col, row]}
            this.loading = new Set(); // Set url
            this.loaded = new Map(); // Map {url : sprite }
            this.loadQueue = [];
        }

        /** Schedules a tile url for loading. The loading itself must be triggered
        by a call to loadOneTile or loadAll

        * @param {String} url - the url of the texture / tile
        * @param {Number} col - the tile col
        * @param {Number} row - the tile row
        **/
        schedule(url, col, row) {
            if (this.loaded.has(url)) return false
            if (this.loading.has(url)) return false

            //Tile.schedule(url)
            this.map.set(url, [col, row]);
            this.loading.add(url);
            this.loadQueue.push(url);
            return true
        }

        unschedule(url) {
            if (this.loaded.has(url)) this.loaded.delete(url);
            if (this.loading.has(url)) this.loading.delete(url);
            //Tile.unschedule(url)
            this.loadQueue = this.loadQueue.filter(item => item != url);
        }

        /** Cancels loading by clearing the load queue **/
        cancel() {
            this.loadQueue = [];
            this.loading.clear();
        }

        /** Destroys alls collections. **/
        destroy() {
            this.setup();
        }

        /** Private method. Informs the tile layer about a texture for a given url.
         * Creates the sprite for the loaded texture and informs the tile layer.
         * @param {String} url - the url
         * @param {Object} texture - the loaded resource
         **/
        _textureAvailable(url, col, row, texture) {
            let tile = this.loaded.get(url);
            if (tile != null) {
                console.warn("Tile already loaded");
                tile.unregister();
            }
            try {
                tile = new Tile(texture, url);
                this.loaded.set(url, tile);
                this.tiles.tileAvailable(tile, col, row, url);
            } catch (error) {
                console.warn("Tile loading error", error);
            }
            
        }
    }

    /**
     * Uses the PIXI Loader but can be replaced with othe loaders implementing
     * the public methods without underscore.
     * Calls the Tiles.tileAvailable method if the texture is available.
     **/
    class PIXITileLoader extends TileLoader {

        constructor(tiles, compression) {
            super(tiles);
            this.destroyed = false;
            this.loader = new PIXI.loaders.Loader();
            this.loader.on('load', this._onLoaded.bind(this));
            this.loader.on('error', this._onError.bind(this));
            if (compression) {
                this.loader.pre(PIXI.compressedTextures.imageParser());
            }
        }

        schedule(url, col, row) {
            // Overwritten schedule to avoid BaseTexture and Texture already loaded errors.
            if (this.loaded.has(url)) return false
            if (this.loading.has(url)) return false

            //Tile.schedule(url)
            let reusableTexture = Tile.textureAvailable(url);
            if (reusableTexture) {
                if (this.debug) console.log('Texture reusable', reusableTexture);
                this._textureAvailable(url, col, row, reusableTexture);
                return false
            }
            let texture = PIXI.utils.TextureCache[url];
            if (texture) {
                if (this.debug) console.log('Texture already loaded', texture);
                this._textureAvailable(url, col, row, texture);
                return false
            }
            let base = PIXI.utils.BaseTextureCache[url];
            if (base) {
                if (this.debug) console.log('BaseTexture already loaded', base);
                let texture = new PIXI.Texture(base);
                this._textureAvailable(url, col, row, texture);
                return false
            }
            return super.schedule(url, col, row)
        }

        /** Load one and only one of the scheduled tiles **/
        loadOneTile() {
            if (this.destroyed)
                return
            this._loadOneTile();
        }

        /** Load all scheduled tiles **/
        loadAll() {
            if (this.destroyed)
                return
            this._loadAllTiles();
        }

        /** Destroys the loader completly **/
        destroy() {
            this.destroyed = true;
            super.destroy();
            try {
                this.loader.reset();
            } catch (error) {
                console.warn("Error while resetting loader", error);
            }
        }

        _onError(loader, error) {
            console.warn('Cannot load', error);
        }

        /** Private method. Called by the PIXI loader after each successfull
         * loading of a single tile.
         * Creates the sprite for the loaded texture and informs the tile layer.
         * @param {Object} loader - the loader instance
         * @param {Object} resource - the loaded resource with url and texture attr
         **/
        _onLoaded(loader, resource) {
            if (this.destroyed) {
                let texture = resource.texture;
                let url = resource.url;
                Tile.lateTexture(url, texture);
                console.warn("Received resource after destroy", texture);
                return
            }
            try {
                let [col, row] = this.map.get(resource.url);
                this._textureAvailable(resource.url, col, row, resource.texture);
            }
            catch (err) {
                console.warn("Texture unavailable: " + err.message);
            }
        }

        /** Private method: loads one tile from the queue. **/
        _loadOneTile(retry = 1) {
            //console.log("_loadOneTile")
            if (this.destroyed) {
                //console.warn("_loadOneTile after destroy")
                return
            }
            if (this.loader.loading) {
                setTimeout(() => {
                    this._loadOneTile();
                }, retry);
                return
            }
            if (this.loadQueue.length > 0) {
                let url = this.loadQueue.pop();
                this.loader.add(url, url);
                this.loader.load();
            }
        }

        /** Private method: loads all tiles from the queue in batches. Batches are
        helpfull to avoid loading tiles that are no longer needed because the
        user has already zoomed to a different level.**/
        _loadAllTiles(batchSize = 8, retry = 16) {
            if (this.destroyed) {
                return
            }
            if (this.loadQueue.length > 0) {
                if (this.loader.loading) {
                    //console.log("Loader busy", this.loadQueue.length)
                    setTimeout(() => {
                        this._loadAllTiles();
                    }, retry);
                    return
                }
                let i = 0;
                let urls = [];
                while (i < batchSize && this.loadQueue.length > 0) {
                    let url = this.loadQueue.pop();
                    if (!this.loaded.has(url)) {
                        let resource = this.loader.resources[url];
                        if (resource) {
                            console.log("Resource already added", url);
                        }
                        else {
                            urls.push(url);
                            i += 1;
                        }
                    }
                }
                this.loader.add(urls).load(() => {
                    this._loadAllTiles();
                });
            }
        }
    }


    /**
     * Uses Workers but can be replaced with other loaders implementing
     * the public methods without underscore.
     * Calls the Tiles.tileAvailable method if the texture is available.
     **/
    class WorkerTileLoader extends TileLoader {

        constructor(tiles, workerPath) {
            super(tiles);

            let worker = this.worker = new Worker(workerPath);

            worker.onmessage = (event) => {
                if (event.data.success) {
                    let { url, col, row, buffer } = event.data;
                    //console.log("WorkerTileLoader.loaded", url, buffer)
                    let CompressedImage = PIXI.compressedTextures.CompressedImage;
                    let compressed = CompressedImage.loadFromArrayBuffer(buffer, url);
                    let base = new PIXI.BaseTexture(compressed);
                    let texture = new PIXI.Texture(base);
                    this._textureAvailable(url, col, row, texture);
                }
            };
        }

        loadOne() {
            if (this.loadQueue.length > 0) {
                let url = this.loadQueue.pop();
                let [col, row] = this.map.get(url);
                let tile = [col, row, url];
                this.worker.postMessage({ command: "load", tiles: [tile] });
            }
        }

        loadAll() {
            let tiles = [];
            while (this.loadQueue.length > 0) {
                let url = this.loadQueue.pop();
                let [col, row] = this.map.get(url);
                tiles.push([col, row, url]);
            }
            this.worker.postMessage({ command: "load", tiles });
        }

        cancel() {
            super.cancel();
            this.worker.postMessage({ command: "abort" });
        }

        destroy() {
            this.worker.postMessage({ command: "abort" });
            this.worker.terminate();
            this.worker = null;
            super.destroy();
        }
    }

    /**
     * A layer of tiles that represents a zoom level of a DeepZoomImage as a grid
     * of sprites.
     * @constructor
     * @param {number} level - the zoom level of the tile layer
     * @param {DeepZoomImage} view - the zoomable image the layer belongs to
     * @param {number} scale - the scale of the tile layer
     * @param {number} cols - the number of columns of the layer
     * @param {number} rows - the number of rows of the layer
     * @param {number} width - the width of the layer in pixel
     * @param {number} height - the height of the layer in pixel
     * @param {number} tileSize - the size of a single tile in pixel
     * @param {number} overlap - the overlap of the tiles in pixel
     * @param {number} fadeInTime - time needed to fade in tiles if TweenLite is set
     **/
    class Tiles extends PIXI.Container {
        constructor(
            level,
            view,
            scale,
            cols,
            rows,
            width,
            height,
            tileSize,
            overlap,
            fadeInTime = 0.33
        ) {
            super();
            this.debug = false;
            this.showGrid = false;
            this.view = view;
            this.level = level;
            this.cols = cols;
            this.rows = rows;
            this.pixelWidth = width;
            this.pixelHeight = height;
            this.tileSize = tileSize;
            this.overlap = overlap;
            this.needed = new Map(); // url as key, [col, row] as value
            this.requested = new Set();
            this.available = new Map();
            this.scale.set(scale, scale);
            this.tileScale = scale;
            this.fadeInTime = fadeInTime;
            this.keep = false;
            if (this.view.useWorker && view.info.compression && view.info.compression.length > 0) {
                this.loader = new WorkerTileLoader(this, this.view.useWorker);
            } else {
                this.loader = new PIXITileLoader(this, view.info.compression);
            }
            this.interactive = false;
            this._highlight = null;

            this._info = null;

            this._centerPoint = null;
            this._boundsRect = null;

            this.infoColor = Colors.random();
            this.pprint();
            this.destroyed = false;
        }



        /** Tests whether all tiles are loaded. **/
        isComplete() {
            return this.cols * this.rows === this.children.length
        }

        /** Returns the highligh graphics layer for debugging purposes.
         **/
        get highlight() {
            if (this._highlight == null) {
                let graphics = new PIXI.Graphics();
                graphics.beginFill(0xffff00, 0.1);
                graphics.lineStyle(2, 0xffff00);
                graphics.drawRect(1, 1, this.tileSize - 2, this.tileSize - 2);
                graphics.endFill();
                graphics.interactive = false;
                this._highlight = graphics;
            }
            return this._highlight
        }

        /** Returns the highligh graphics layer for debugging purposes.
         **/
        get info() {
            if (this._info == null) {
                let graphics = new PIXI.Graphics();
                graphics.lineStyle(4, 0xff0000);
                graphics.interactive = false;
                this._info = graphics;
                this.addChild(this._info);
            }
            return this._info
        }

        /** Helper method pretty printing debug information. **/
        pprint() {
            if (this.debug)
                console.log(
                    'Tiles level: ' +
                    this.level +
                    ' scale: ' +
                    this.scale.x +
                    ' cols: ' +
                    this.cols +
                    ' rows: ' +
                    this.rows +
                    ' w: ' +
                    this.pixelWidth +
                    ' h: ' +
                    this.pixelHeight +
                    ' tsize:' +
                    this.tileSize
                );
        }

        /** Computes the tile position and obeys the overlap.
         * @param {number} col - The column of the tile
         * @param {number} row - The row of the tile
         * @returns {PIXI.Point} obj
         **/
        tilePosition(col, row) {
            let x = col * this.tileSize;
            let y = row * this.tileSize;
            let overlap = this.overlap;
            if (col != 0) {
                x -= overlap;
            }
            if (row != 0) {
                y -= overlap;
            }
            return new PIXI.Point(x, y)
        }

        /** Computes the tile size without overlap
         * @param {number} col - The column of the tile
         * @param {number} row - The row of the tile
         * @returns {PIXI.Point} obj
         **/
        tileDimensions(col, row) {
            let w = this.tileSize;
            let h = this.tileSize;
            let pos = this.tilePosition(col, row);
            if (col == this.cols - 1) {
                w = this.pixelWidth - pos.x;
            }
            if (row == this.rows - 1) {
                h = this.pixelHeight - pos.y;
            }
            return new PIXI.Point(w, h)
        }

        /** Method to support debugging. Highlights the specified tile at col, row **/
        highlightTile(col, row) {
            if (col > -1 && row > -1 && col < this.cols && row < this.rows) {
                let graphics = this.highlight;
                let dim = this.tileDimensions(col, row);
                graphics.position = this.tilePosition(col, row);
                graphics.clear();
                graphics.beginFill(0xff00ff, 0.1);
                graphics.lineStyle(2, 0xffff00);
                graphics.drawRect(1, 1, dim.x - 2, dim.y - 2);
                graphics.endFill();
                this.addChild(this.highlight);
            } else {
                this.removeChild(this.highlight);
            }
        }

        /** Loads the tiles for the given urls and adds the tiles as sprites.
         * @param {array} urlpos - An array of URL, pos pairs
         * @param {boolean} onlyone - Loads only on tile at a time if true
         **/
        loadTiles(urlpos, onlyone, refCol, refRow) {
            if (this.showGrid) {
                this.highlightTile(refCol, refRow);
            }
            urlpos.forEach(d => {
                let [url, col, row] = d;
                if (this.loader.schedule(url, col, row)) {
                    if (onlyone) {
                        return this.loader.loadOneTile()
                    }
                }
            });
            this.loader.loadAll();
        }

        /** Private method: add a red border to a tile for debugging purposes. **/
        _addTileBorder(tile, col, row) {
            let dim = this.tileDimensions(col, row);
            let graphics = new PIXI.Graphics();
            graphics.beginFill(0, 0);
            graphics.lineStyle(2, 0xff0000);
            graphics.drawRect(1, 1, dim.x - 2, dim.y - 2);
            graphics.endFill();
            tile.addChild(graphics);
        }

        /** Adds a tile. **/
        addTile(tile, col, row, url) {
            if (this.available.has(url)) {
                console.warn('Trying to add available tile');
                return
            }
            this.addChildAt(tile, 0);
            this.available.set(url, tile);
            if (this.destroyed) {
                console.warn('Adding to destroyed tiles layer');
            }
            // this._addTileBorder(tile, col, row)
        }

        /** Called by the loader after each successfull loading of a single tile.
         * Adds the sprite to the tile layer.
         * @param {Object} tile - the loaded tile sprite
         * @param {Number} col - the col position
         * @param {Number} row - the rowposition
         **/
        tileAvailable(tile, col, row, url) {
            let pos = this.tilePosition(col, row);
            if (this.showGrid) {
                this._addTileBorder(tile, col, row);
            }
            tile.position = pos;
            tile.interactive = false;
            if (TweenLite) {
                tile.alpha = 0;
                TweenLite.to(tile, this.fadeInTime, { alpha: this.alpha });
            }
            this.addTile(tile, col, row, url);
        }

        /** Destroys the tiles layer and destroys the loader. Async load calls are
         * cancelled.
         **/
        destroy() {
            this.destroyed = true;
            this.loader.destroy();
            super.destroy({ children: true }); // Calls destroyChildren
            this.available.clear();
            this.requested.clear();
            this.needed.clear();
        }

        destroyTile(url, tile) {
            this.loader.unschedule(url);
            this.removeChild(tile);
            tile.destroy();
            this.available.delete(url);
        }

        destroyTileByUrl(url) {
            if (this.available.has(url)) {
                let tile = this.available.get(url);
                this.destroyTile(url, tile);
            }
        }

        /* Destroys the tiles which are not with the bounds of the app to free
        * memory.
        **/
        destroyTiles(quadTrees) {
            let count = 0;
            for (let [url, tile] of this.available.entries()) {
                if (!quadTrees.has(url)) {
                    this.destroyTile(url, tile);
                    count += 1;
                }
            }
            if (count && this.debug)
                console.log('destroyTiles', this.level, count);
        }

        destroyUnneededTiles() {
            let count = 0;
            for (let [url, tile] of this.available.entries()) {
                if (!this.needed.has(url)) {
                    this.destroyTile(url, tile);
                    count += 1;
                }
            }
            if (count && this.debug)
                console.log('destroyUnneededTiles', this.level, count);
        }

        highlightInfos() {
            let graphics = this.info;
            let color = this.infoColor;
            graphics.clear();
            graphics.lineStyle(2, color);
            for (let [col, row] of this.needed.values()) {
                let dim = this.tileDimensions(col, row);
                let pos = this.tilePosition(col, row);
                graphics.beginFill(color, 0.2);
                graphics.drawRect(pos.x + 1, pos.y + 1, dim.x - 2, dim.y - 2);
                graphics.endFill();
            }
            let r = this._boundsRect;
            if (r != null) {
                graphics.lineStyle(20, color);
                graphics.drawRect(r.x, r.y, r.width, r.height);
                graphics.moveTo(r.x, r.y);
                graphics.lineTo(r.x + r.width, r.y + r.height);

                graphics.moveTo(r.x, r.y + r.height);
                graphics.lineTo(r.x + r.width, r.y);
            }

            let p = this._centerPoint;
            if (p != null) {
                graphics.drawCircle(p.x, p.y, 20);
            }
        }

        tintTiles(quadTrees) {
            for (let [url, tile] of this.available.entries()) {
                if (!quadTrees.has(url)) tile.tint = 0xff0000;
            }
        }

        untintTiles() {
            for (let [url, tile] of this.available.entries()) {
                tile.tint = 0xffffff;
            }
        }
    }

    function isEven(n) {
        return n % 2 == 0
    }

    /**
     * A utility class that holds information typically provided by DZI files, i.e.
     * height and width of the overall image, overlap, and image type.
     *
     * @constructor
     * @param {obj} attrs - A JSON-Object holding the listed keys and values
     * @example
     *     {
     *         "tileSize": 1024,
     *         "format": "jpeg",
     *         "overlap": 1,
     *         "height": 4794,
     *         "width": 4095,
     *         "clip": { "minLevel": 12, "maxLevel": 20, "startCol": 301436, "startRow": 354060 },
     *                   // optional: minLevel and maxLevel define the level bounds
     *                   // startCol: first col at maxLevel
     *                   // startRow: first row at maxLevel
     *         "path": "var/Vermeer/Vermeer_files",
     *         "type": "dzi",  // optional: dzi (default) or map
     *         "urlTileTemplate": "{path}/{level}/{column}/{row}.{format}"
     *           // optional: {path}/{level}/{column}_{row}.{format} (default) or
     *           // a template String with the format of the URL
     *     }
     */
    class DeepZoomInfo {
        constructor(attrs) {
            for (let key in attrs) {
                this[key] = attrs[key];
            }
            this.maxLevel = 0; // The highest level number, typically corresponds to the
            // number in the file system for the folder with tiles
            this.clip = this.clip || null; // e.g. { level: 12, col: 301436, row: 354060 }
            this.type = this.type || 'dzi';
            this.urlTileTemplate =
                this.urlTileTemplate || '{path}/{level}/{column}_{row}.{format}';
            this.setupDimensions();
        }

        /* Computes the needed number of layers from the width and height
        *  of the image. Note that this includes the level 0, i.e. 0 ... 4
        * means that 5 levels exist.
        **/
        numLevels() {
            let maxDimension = Math.max(this.width, this.height);
            let boundary = this.type === 'dzi' ? 1 : this.tileSize;
            let numLevels = 0;
            while (maxDimension >= boundary) {
                maxDimension /= 2;
                numLevels++;
            }
            return numLevels
        }

        /** Computes the scale at the given level.
         * @param {number} level - The level of the wanted layer
         * @returns {number} scale
         **/
        getScale(level) {
            let scale = 1;
            if (this.type === 'dzi') {
                scale = Math.pow(0.5, this.maxLevel - level + 1);
            } else {
                scale = Math.pow(0.5, this.maxLevel - level);
            }
            return scale
        }

        /** Computes the scaled width and height of the given level.
         * @param {number} level - The level of the wanted layer
         * @returns {array} size - The width and height
         **/
        getDimensions(level) {
            let scale = this.getScale(level);
            let w = Math.ceil(this.width * scale);
            let h = Math.ceil(this.height * scale);
            return [w, h]
        }

        /** Computes the number of cols and rows of tiles.
         * @param {number} level - The level of the wanted layer
         * @returns {array} size - The cols and rows
         **/
        getNumTiles(level) {
            let dim = this.getDimensions(level);
            let cols = Math.ceil(dim[0] / this.tileSize);
            let rows = Math.ceil(dim[1] / this.tileSize);
            if (this.clip) {
                let rest = this.rests[level];
                if (rest) {
                    if (rest.restCol) {
                        cols += 1;
                    }
                    if (rest.restRows) {
                        rows += 1;
                    }
                }
            }
            return [cols, rows]
        }

        setupDimensions(loadBaseImage = false) {
            /** Setup instance variables and load the base image, i.e. the largest
            image that can be represented as a single tile.
            @private
            **/
            let ww = this.width;
            let hh = this.height;
            let scale = 1.0;
            let level = 0;
            let single = 0;
            const tsize = this.tileSize;

            if (this.clip) {
                this.baseLevel = this.clip.minLevel;
                this.maxLevel = this.clip.maxLevel;
                this.baseImage = null;
                this.size = this.getDimensions(this.baseLevel);
                this.offsets = {};
                this.rests = {};
                let startCol = this.clip.startCol;
                let startRow = this.clip.startRow;
                let floatStartCol = startCol;
                let floatStartRow = startRow;
                for (let i = this.maxLevel; i >= this.baseLevel; i--) {
                    this.offsets[i] = { startCol, startRow };
                    let restCol = floatStartCol % 1;
                    let restRow = floatStartRow % 1;
                    this.rests[i] = { restCol, restRow };
                    startCol = Math.floor(startCol / 2);
                    startRow = Math.floor(startRow / 2);
                    floatStartCol /= 2;
                    floatStartRow /= 2;
                }
            } else {
                const boundary = this.type === 'dzi' ? 1.0 : tsize;
                while (ww > boundary && hh > boundary) {
                    if (ww >= tsize && hh >= tsize) {
                        single += 1;
                    }
                    scale = scale / 2.0;
                    ww = Math.ceil(this.width * scale);
                    hh = Math.ceil(this.height * scale);
                    level += 1;
                }
                this.baseLevel = level - single;
                this.maxLevel = this.numLevels() - 1;
                this.baseURL = this.urlForTile(this.baseLevel, 0, 0, false);

                if (loadBaseImage) {
                    this.imageForURL(this.baseURL, e => {
                        this.size = [e.target.naturalWidth, e.target.naturalHeight];
                        this.baseImage = e.target;
                    });
                } else {
                    this.baseImage = null;
                    this.size = this.getDimensions(this.baseLevel);
                }
            }
        }

        get maxLoadableLevel() {
            if (this.clip) {
                return this.maxLevel
            }
            return this.type === 'dzi' ? this.maxLevel : this.maxLevel
        }

        /** Computes the url for the given level, column and and row.
         * @param {number} level - The level of the wanted layer
         * @param {number} column - The column of the tile
         * @param {number} row - The row of the tile
         * @returns {string} url
         **/
        urlForTile(level, column, row, compressed = true) {
            let format = this.format;
            if (compressed && this.compression) {
                let supported = Capabilities.isIOS ? 'pvr' : 'dds';
                if (this.compression.indexOf(supported) >= 0) {
                    format = supported;
                }
            }
            if (this.clip) {
                let offset = this.offsets[level];
                if (offset) {
                    let { startCol, startRow } = offset;
                    column += startCol;
                    row += startRow;
                }
            }
            let url = this.urlTileTemplate
                .replace(/\{path\}/g, this.path)
                .replace(/\{level\}/g, level)
                .replace(/\{row\}/g, row)
                .replace(/\{column\}/g, column)
                .replace(/\{format\}/g, format);
            return url
        }

        /** Loads the image for the given url and executes a callback function
        on completion.
        * @param {string} url - The url of the tile
        * @param {function} complete - The callback function
        * @returns {Image} obj
        **/
        imageForURL(url, complete) {
            let img = new Image();
            img.onload = complete.bind(this);
            img.src = url;
            return img
        }

        /** Computes the columns and rows as well as scaled width and height.
         * @param {number} level - The level of the wanted layer
         * @returns {array} [cols, rows, width, height]
         **/
        dimensions(level) {
            let dim = this.getDimensions(level);
            let tiles = this.getNumTiles(level);
            return [tiles[0], tiles[1], dim[0], dim[1]]
        }

        test() {
            //console.log("w=" + this.width + " h=" + this.height + " maxlevel=" + this.maxLevel + " base=" + this.baseLevel)
            for (let i = 0; i <= this.maxLevel; i++) {
                console.log(
                    ' ' +
                    i +
                    ' -> ' +
                    this.getScale(i) +
                    ' [' +
                    this.dimensions(i) +
                    ']'
                );
            }
            console.log(this.urlForTile(this.baseLevel, 0, 0));
        }
    }

    /**
     * A utility class that describes a quad tree of tiles. Each tile on a given
     * level has up to four corresponding tiles on the next level. A TileQuadNode
     * uses the attributes nw (i.e. northwest), ne, sw, se to link to the
     * quad nodes on the next level. The previous attributes links to the quad
     * one level below that holds the given quad as nw, ne, sw, or se.
     * We use this node class because we need a representation of tiles that are
     * needed but not loaded yet to compute tiles which can be abandoned to reduce
     * the memory pressure.
     *
     * @constructor
     * @param {level} Number - The level the quad node belongs to
     * @param {col} Number - The col of the quad
     * @param {row} Number - The level the quad node belongs to
     * @param {url} String - The level the quad node belongs to
     */
    class TileQuadNode {
        constructor(level, col, row, url) {
            this.level = level;
            this.col = col;
            this.row = row;
            this.url = url;
            this.nw = null;
            this.ne = null;
            this.sw = null;
            this.se = null;
            this.previous = null;
        }

        /** Return True if this node has no successors and can be used as
        an indicator of tiles to free.
        **/
        noQuads() {
            if (this.previous === null) return false
            return (
                this.nw === null &&
                this.ne === null &&
                this.sw === null &&
                this.se === null
            )
        }

        /** Unlink the given quad node

        * @param {node} TileQuadNode - The TileQuadNode to remove
        **/
        unlink(node) {
            if (this.nw === node) this.nw = null;
            if (this.ne === node) this.ne = null;
            if (this.sw === node) this.sw = null;
            if (this.se === node) this.se = null;
        }

        /** Link this quad node to the given previous node. Use the north
        * and west flags to address nw, ne, sw, and se.

        * @param {node} TileQuadNode - The TileQuadNode to remove
        * @param {north} Boolean - Link to north (true) or south (false)
        * @param {west} Boolean - Link to west (true) or east (false)
        **/
        link(north, west, previous) {
            this.previous = previous;
            if (north) {
                if (west) {
                    previous.nw = this;
                } else {
                    previous.ne = this;
                }
            } else {
                if (west) {
                    previous.sw = this;
                } else {
                    previous.se = this;
                }
            }
        }
    }


    /**
     * The main class of a deeply zoomable image that is represented by a hierarchy
     * of tile layers for each zoom level. This gives the user the impression that
     * even huge pictures (up to gigapixel-images) can be zoomed instantaneously,
     * since the tiles at smaller levels are scaled immediately and overloaded by
     * more detailed tiles at the larger level as fast as possible.

     * @constructor
     * @param {DeepZoomInfo} deepZoomInfo - Information extracted from a JSON-Object
     */
    class DeepZoomImage extends PIXI.Container {
        constructor(
            deepZoomInfo,
            {
                debug = false,
                shadow = false,
                center = false,
                world = null,               // Defines the world bounds the images lives in
                highResolution = true,
                autoLoadTiles = true,
                useWorker = '',
                minimumLevel = 0,
                alpha = 1,
                app = window.app
            } = {}
        ) {
            super();
            this.app = app;
            this.debug = debug;
            this.shadow = shadow;
            this.world = world;
            this.useWorker = useWorker;
            this.resolution = highResolution
                ? Math.round(window.devicePixelRatio)
                : 1;
            this.alpha = alpha;
            this.fastLoads = 0;
            this.active = true;
            this.autoLoadTiles = autoLoadTiles;
            this.minimumLevel = minimumLevel;
            this.quadTrees = new Map(); // url as keys, TileQuadNodes as values
            this.setup(deepZoomInfo, center);
        }

        get point() {
            if (this._point == null) {
                let graphics = new PIXI.Graphics();
                graphics.lineStyle(2, 0x00ff00);
                graphics.drawCircle(0, 0, 2);
                graphics.interactive = false;
                this._point = graphics;
            }
            return this._point
        }

        /** Reads the DeepZoomInfo object and initializes all tile layers.
         * Called by the constructor.
         * Creates the sprite for the loaded texture and add the sprite to the tile
         * layer.
         * @param {Object} deepZoomInfo - the DeepZoomInfo instance
         * @param {boolean} center - If true ensures that the pivot is set to the center
         **/
        setup(deepZoomInfo, center) {
            this.info = deepZoomInfo;
            this.interactive = true;
            this.tileLayers = {};

            this._foreground = null;
            this.tileContainer = new PIXI.Container();
            this.tileContainer.interactive = false;

            let [w, h] = this.baseSize;
            if (this.shadow) {
                this.filters = [new PIXI.filters.DropShadowFilter(45, 3)];
            }
            this.addChild(this.tileContainer);

            if (deepZoomInfo.clip) {
                let mask = new PIXI.Graphics();
                mask.beginFill(1, 1);
                mask.drawRect(0, 0, w, h);
                mask.endFill();
                this.mask = mask;
                mask.alpha = 0;
                this.addChild(mask);
                this.minimumLevel = deepZoomInfo.baseLevel;
            }
            this.currentLevel = Math.max(this.minimumLevel, deepZoomInfo.baseLevel);
            //console.log("autoLoadTiles", this.autoLoadTiles)
            if (this.autoLoadTiles) {
                this.setupTiles(center);
            }
        }

        /** Default setup method for tiles. Loads all tiles of the current level.
        Can be overwritten in subclasses.
        @param {boolean} center - If true ensures that the pivot is set to the center
        **/
        setupTiles(center = false) {
            // First load background tile
            let tiles = this.ensureAllTiles(this.currentLevel);
            if (center) {
                this.pivot.set(w / 2, h / 2);
            }
            let scaleLevel = this.levelForScale(1);
            this.ensureAllTiles(scaleLevel);
        }

        removeTileQuadNode(level, col, row, url) {
            if (this.quadTrees.has(url)) {
                let quad = this.quadTrees.get(url);
                this.tileQuadRemoved(quad);
                this.quadTrees.delete(url);
            }
        }

        addTileQuadNode(level, col, row, url) {
            if (this.quadTrees.has(url)) return this.quadTrees.get(url)
            let quad = new TileQuadNode(level, col, row, url);
            this.quadTrees.set(url, quad);
            this.tileQuadAdded(quad);
            return quad
        }

        tileQuadRemoved(quad) {
            let below = quad.previous;
            // if (this.debug) console.log("tileQuadRemoved", quad)
            if (below) {
                below.unlink(quad);
                if (below.noQuads()) {
                    if (this.debug) console.log('Removed tile below');
                    let levelBelow = quad.level - 1;
                    if (levelBelow < this.minimumLevel) return
                    let c = Math.floor(quad.col / 2);
                    let r = Math.floor(quad.row / 2);
                    let urlBelow = this.info.urlForTile(levelBelow, c, r);
                    if (this.quadTrees.has(urlBelow)) {
                        this.removeTileQuadNode(levelBelow, c, r, urlBelow);
                    }
                }
            }
        }

        tileQuadAdded(quad) {
            let levelBelow = quad.level - 1;
            if (levelBelow < this.minimumLevel) return
            //if (this.debug) console.log("tileQuadAdded", quad)
            let c = Math.floor(quad.col / 2);
            let r = Math.floor(quad.row / 2);
            let urlBelow = this.info.urlForTile(levelBelow, c, r);
            let below = null;
            if (!this.quadTrees.has(urlBelow)) {
                below = this.addTileQuadNode(levelBelow, c, r, urlBelow);
                quad.link(isEven(quad.row), isEven(quad.col), below);
            }
        }

        /** Returns the tile layer level that corresponds to the given scale.
         * @param {number} scale - the scale factor
         **/
        levelForScale(scale) {
            let level = Math.round(Math.log2(scale * this.resolution)); // Math.floor(Math.log2(event.scale))+1
            let newLevel = this.info.baseLevel + Math.max(level, 0);
            return Math.min(newLevel, this.info.maxLoadableLevel)
        }

        /** Returns the tile layer level that corresponds to the given scale.
         * @param {number} scale - the scale factor
         **/
        levelAndAlphaForScale(scale) {
            let value = Math.log2(scale * this.resolution);
            let level = Math.round(value);
            let newLevel = this.info.baseLevel + Math.max(level, 0);

            return { level: Math.min(newLevel, this.info.maxLoadableLevel), alpha: value - level }
        }

        /** Adds a tile layer to the DeepZoomImage.
         * @param {string} key - the access key
         * @param {Tiles} tiles - the tile layer object
         **/
        addTiles(key, tiles) {
            if (key in this.tileLayers) {
                console.warn('Tiles already availabl', key);
            }
            this.tileContainer.addChild(tiles);
            this.tileLayers[key] = tiles;
        }

        destroyTiles(key) {
            let tiles = this.tileLayers[key];
            this.tileContainer.removeChild(tiles);
            tiles.destroy();
            delete this.tileLayers[key];
        }

        /** Getter for PIXI.Container foreground layer.
         * Adds a PIXI.Container if necessary.
         **/
        get foreground() {
            if (this._foreground == null) {
                this._foreground = new PIXI.Container();
                this.addChild(this._foreground);
            }
            return this._foreground
        }

        /** Getter for the DeepZoomInfo base level size.
         **/
        get baseSize() {
            return this.info.getDimensions(this.info.baseLevel)
        }

        /** Getter for the current scaled size in pixels.
         **/
        get pixelSize() {
            let [w, h] = this.baseSize;
            return [w * this.scale.x, h * this.scale.y]
        }

        /** Getter for the max scale factor.
         **/
        get maxScale() {
            let delta = this.info.maxLevel - this.info.baseLevel;
            return Math.pow(2, delta) / this.resolution * 2
        }

        /** Getter for the current width.
         **/
        get width() {
            return this.pixelSize[0]
        }

        /** Getter for the current height.
         **/
        get height() {
            return this.pixelSize[1]
        }


        /* Overrides PIXI.Container.hitArea()
         * Allows to optimize the hit testing. Container with hit areas are directly
         * hit tested without consideration of children.
         */
        get hitArea() {
            // Defining the hitArea resulted hitting the scatter in masked area
            // when a mask was used (@Tüsch[submaps]). Removing the hitArea() altogether
            // broke the interaction in other projects (@googleart).
            // Fix: When masked, the hitArea is ignored by returning null.
            // TODO: test if childs are hittested, without setting interactiveChildren.
            // Opel, 03-05-2018
            if (this.mask) {
                return null
            }
            return this
        }

        /* Overrides PIXI.Container.contains()
         * Allows to optimize the hit testing.
         */
        contains(x, y) {
            let [w, h] = this.baseSize;
            return x >= 0 && x <= w && y >= 0 && y <= h
        }

        /** Overrides PIXI.Container._calculateBounds()
         * Only considers the base size and reduces the calculation to a single
         * rect.
         */
        _calculateBounds() {
            let [w, h] = this.baseSize;
            this._bounds.addFrame(this.transform, 0, 0, w, h);
        }

        /** Overrides PIXI.Container.calculateBounds()
         * Skips the children and only considers the deep zoom base size. Calls
         * the also overwritten _calculateBounds method.
         */
        calculateBounds() {
            this._bounds.clear();
            this._calculateBounds();
            this._lastBoundsID = this._boundsID;
        }

        /** Returns a single sprite that can be used a thumbnail representation of
         * large images.
         * @return {Sprite} sprite - A sprite with a single tile texture
         */
        thumbnail() {
            return new PIXI.Sprite.fromImage(this.info.baseURL)
        }

        /** Returns a list of all tiles of a given level.
         * @param {Tiles} tiles - the grid of tiles
         * @param {number} level - The zoom level of the grid
         * @return {Array[]} - An array of [url, col, row] arrays
         **/
        allTiles(tiles, level) {
            let result = [];
            for (let col = 0; col < tiles.cols; col++) {
                for (let row = 0; row < tiles.rows; row++) {
                    let url = this.info.urlForTile(level, col, row);
                    result.push([url, col, row]);
                }
            }
            return result
        }

        worldBounds() {
            let viewBounds = this.app.scene.bounds || this.app.scene.getBounds(); 
            // Using getBounds extends visible scope after loading tiles and leads
            // to excessive loading. So we prefer bounds over getBounds()
            if (this.world != null) {
                let bounds = this.world.bounds;
                let x = Math.max(-bounds.width, bounds.x);
                let y = Math.max(-bounds.height, bounds.y);
                let width = Math.min(viewBounds.width, bounds.width);
                let height = Math.min(viewBounds.height, bounds.height);
                //console.log("worldBounds new", { x, y, width, height })
                return { x, y, width, height }
            }
            //console.log("worldBounds old", viewBounds)
            return viewBounds
        }

        /** Loads all tiles that are needed to fill the app bounds.
         * @param {Tiles} tiles - the grid of tiles
         * @param {number} level - The zoom level of the grid
         * @param {boolean} debug
         * @return {Array[]} - An array of [url, col, row] arrays
         */
        neededTiles(tiles, level, debug = false) {
            let needed = [];
            let tsize = tiles.tileSize;
            let worldBounds = this.worldBounds();
            let maxWidth = worldBounds.width;
            let maxHeight = worldBounds.height;

            let pointInWindow = new PIXI.Point();
            let worldTopLeft = new PIXI.Point(worldBounds.x, worldBounds.y);
            let worldBottomRight = new PIXI.Point(worldBounds.x + maxWidth, worldBounds.y + maxHeight);
            let worldCenter = new PIXI.Point(worldBounds.x + maxWidth / 2, worldBounds.y + maxHeight / 2);
            let tilesCenter = tiles.toLocal(worldCenter);

            let topLeft = tiles.toLocal(worldTopLeft);
            let bottomRight = tiles.toLocal(worldBottomRight);
            tiles._centerPoint = tilesCenter;
            let bounds = new PIXI.Rectangle(
                topLeft.x,
                topLeft.y,
                bottomRight.x - topLeft.x,
                bottomRight.y - topLeft.y);

            tiles._boundsRect = bounds;

            /* UO: we need a toLocal call here since the transform may need an update
            which is guaranteed by the toLocal method. */
            let centerCol = Math.floor(tilesCenter.x / tsize);
            let centerRow = Math.floor(tilesCenter.y / tsize);

            // Expand because we want to test for included centers
            bounds.x -= tsize / 2;
            bounds.y -= tsize / 2;
            bounds.width += tsize;
            bounds.height += tsize;

            try {
                let maxTilesWidth = Math.ceil(maxWidth / tsize);
                let maxTilesHeight = Math.ceil(maxHeight / tsize);

                maxTilesWidth += 2;
                maxTilesHeight += 2;

                let startCol = Math.max(0, centerCol - maxTilesWidth);
                let endCol = Math.min(tiles.cols, centerCol + maxTilesWidth);

                let startRow = Math.max(0, centerRow - maxTilesHeight);
                let endRow = Math.min(tiles.rows, centerRow + maxTilesHeight);

                for (let col = startCol; col < endCol; col++) {
                    let cx = (col + 0.5) * tsize;
                    for (let row = startRow; row < endRow; row++) {
                        let cy = (row + 0.5) * tsize;
                        let tileCenter = new PIXI.Point(cx, cy);
                        if (bounds.contains(tileCenter.x, tileCenter.y)) {
                            let url = this.info.urlForTile(level, col, row);
                            needed.push([url, col, row]);
                        }
                    }
                }
            } catch (error) {
                console.warn(error.message);
            }
            return { centerCol, centerRow, needed }
        }




        /** Returns all changed tiles for a given level.
         * @param {Tiles} tiles - the grid of tiles
         * @param {number} level - The zoom level of the grid
         * @return {object} - An object with the keys added and removed which values are [url, col, row] arrays
         */
        changedTiles(tiles, level) {
            if (this.debug) console.time('changedTiles');
            let changed = { added: [], removed: [] };
            let newNeeded = new Map();
            let { centerCol, centerRow, needed } = this.neededTiles(tiles, level);
            needed.forEach(d => {
                let [url, col, row] = d;
                newNeeded.set(url, [col, row]);
                if (!tiles.requested.has(url)) {
                    changed.added.push(d);
                }
            });
            for (let url of tiles.needed.keys()) {
                if (!newNeeded.has(url)) {
                    let [col, row] = tiles.needed.get(url);
                    changed.removed.push([url, col, row]);
                }
            }
            tiles.needed = newNeeded;
            if (this.debug) console.log(newNeeded);
            if (this.debug) console.timeEnd('changedTiles');
            return { centerCol, centerRow, changed }
        }

        /** Populates all tiles for a given level.
         * @param {Tiles} tiles - the grid of tiles
         * @param {number} level - The zoom level of the grid
         */
        populateAllTiles(tiles, level) {
            let all = this.allTiles(tiles, level);
            for (let [url, col, row] of all) {
                this.addTileQuadNode(level, col, row, url);
            }
            tiles.loadTiles(all, false, 0, 0);
        }

        /** Loads all tiles that are needed to fill the browser window.
         * If the optional about parameter is provided (as a point with col as x,
         * and row as y attr) the tiles are sorted by the distance to this point.
         *
         * @param {Tiles} tiles - the grid of tiles
         * @param {number} level - The zoom level of the grid
         * Optional parameter:
         * @param {boolean} onlyone - if true only one tile is loaded
         * @param {PIXI.Point} about - point of interaction
         */
        populateTiles(tiles, level, { onlyone = false, about = null } = {}) {
            if (tiles.isComplete())
                return
            let referenceCol = -1;
            let referenceRow = -1;
            let { centerCol, centerRow, changed } = this.changedTiles(tiles, level);
            if (about != null) {
                // We want to load tiles in the focus of the user first, therefore
                // we sort according to the distance of the focus of interaction
                let refPoint = this.toLocal(about);
                let scaledTileSize = tiles.tileSize * tiles.tileScale;
                referenceCol = Math.floor(refPoint.x / scaledTileSize);
                referenceRow = Math.floor(refPoint.y / scaledTileSize);
            }
            else {
                referenceCol = centerCol;
                referenceRow = centerRow;
            }
            referenceCol = centerCol;
            referenceRow = centerRow;

            let removed = changed.removed;
            for (let [url, col, row] of removed) {
                this.removeTileQuadNode(level, col, row, url);
            }
            let added = changed.added;
            if (added.length == 0) return
            for (let [url, col, row] of added) {
                this.addTileQuadNode(level, col, row, url);
            }
            let ref = new PIXI.Point(referenceCol, referenceRow);
            // Note: The array must be sorted in a way that the nearest tiles
            // are at the end of the array since the load queue uses Array.push
            // Array.pop
            added.sort((a, b) => {
                let aa = new PIXI.Point(a[1], a[2]);
                let bb = new PIXI.Point(b[1], b[2]);
                let da = Points.distance(aa, ref);
                let db = Points.distance(bb, ref);
                return db - da
            });
            tiles.loadTiles(added, onlyone, referenceCol, referenceRow);
        }

        /** Private method: creates all tiles for a given level.
         * @param {number} level - The zoom level of the grid
         * @return {Tiles} - tiles
         */
        _createTiles(key, level) {
            let [cols, rows, w, h] = this.info.dimensions(level);
            let increasedLevels = level - this.info.baseLevel;
            let invScale = Math.pow(0.5, increasedLevels);
            let tiles = new Tiles(
                level,
                this,
                invScale,
                cols,
                rows,
                w,
                h,
                this.info.tileSize,
                this.info.overlap
            );
            this.addTiles(key, tiles);
            if (this.info.clip) {
                let rest = this.info.rests[level];
                if (rest) {
                    let x = rest.restCol * this.info.tileSize * invScale;
                    let y = rest.restRow * this.info.tileSize * invScale;
                    tiles.x = -x;
                    tiles.y = -y;
                }
            }
            return tiles
        }

        /** Ensures that all needed tiles of a given level are loaded. Creates
         * a new Tiles layer if necessary
         * @param {number} level - The zoom level of the grid
         * @return {Tiles} tiles
         */
        ensureTiles(level, about) {
            let key = level.toString();
            if (key in this.tileLayers) {
                let tiles = this.tileLayers[key];
                this.populateTiles(tiles, level, { about: about });
                return tiles
            }
            let tiles = this._createTiles(key, level);
            this.populateTiles(tiles, level, { about: about });
            //console.log("ensureTiles", level)
            return tiles
        }

        untintTiles(level) {
            let key = level.toString();
            if (key in this.tileLayers) {
                let tiles = this.tileLayers[key];
            }
        }

        /** Ensures that all tiles of a given level are loaded.
         * @param {number} level - The zoom level of the grid
         */
        ensureAllTiles(level) {
            let key = level.toString();
            if (key in this.tileLayers) {
                let tiles = this.tileLayers[key];
                this.populateAllTiles(tiles, level);
                tiles.keep = true;
                return
            }
            let tiles = this._createTiles(key, level);
            this.populateAllTiles(tiles, level);
            tiles.keep = true;
            return tiles
        }

        hideTilesAboveLevel(level) {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                if (tiles.level > level) {
                    tiles.visible = false;
                }
            });
        }

        /** Destroys all tiles above a given level to ensure that the memory can
         * be reused.
         * @param {number} level - The zoom level of the grid
         */
        destroyTilesAboveLevel(level) {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                if (tiles.level > level && !tiles.keep) {
                    for (let url of tiles.available.keys()) {
                        let quad = this.quadTrees.get(url);
                        if (quad) this.removeTileQuadNode(quad);
                    }
                    this.destroyTiles(key);
                }
            });
        }

        destroyAllTiles() {
            Object.keys(this.tileLayers).forEach(key => {
                this.destroyTiles(key);
            });
        }

        /**
         * Tint tiles in all layers that are no longer needed
         *
         * @memberof DeepZoomImage
         */
        tintObsoleteTiles() {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                tiles.untintTiles();
                if (!tiles.keep) {
                    tiles.tintObsoleteTiles();
                }
            });
        }


        /**
         * Destroy tiles in all layers that are no longer needed
         *
         * @memberof DeepZoomImage
         */
        destroyUnneededTiles() {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                if (!tiles.keep) {
                    tiles.destroyUnneededTiles();
                }
            });
        }

        /**
         * Destroy tiles in all layers that are no longer needed
         *
         * @memberof DeepZoomImage
         */
        destroyObsoleteTiles() {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                if (!tiles.keep) {
                    tiles.destroyObsoleteTiles();
                }
            });
        }

        /**
         * Destroy tiles in all layers that are not part of the
         * visible quadTrees
         *
         * @memberof DeepZoomImage
         */
        destroyTiles() {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                if (!tiles.keep) {
                    tiles.destroyTiles(this.quadTrees);
                }
            });
        }

        /* Tint all tiles
        * @param {number} level - The zoom level of the grid
        */
        tintTilesBelowLevel(level) {
            Object.keys(this.tileLayers).forEach(key => {
                let tiles = this.tileLayers[key];
                if (tiles.level < level) {
                    tiles.tintTiles(this.quadTrees);
                }
            });
        }

        /**
         * Ensure that the given tiles layer is the topmost one and visible.
         * @param {*} tiles 
         */
        bringTilesToFront(tiles) {
            this.tileContainer.addChild(tiles);
            tiles.visible = true;
        }

        /** A callback function that can be used by a Scatter view to inform
         * the zoomable image that it has been moved, rotated or scaled, and should
         * load tiles accordingly.
         * @param {PIXI.Point} translated - the movement of the scatter
         * @param {number} scale - the zoom factor
         * @param {PIXI.Point} about - the anchor point of the zoom
         * @param {boolean} fast - informs the callback to return as fast as possible,
         *  i.e. after loading a single tile
         * @param {boolean} debug - log debug infos
         */
        transformed(event) {
            if (!this.active) {
                return
            }
            let key = this.currentLevel.toString();
            let currentTiles = this.tileLayers[key];
            if (typeof currentTiles == 'undefined') {
                return
            }
            if (event.fast) {
                this.fastLoads += 1;
                this.populateTiles(currentTiles, this.currentLevel, {
                    onlyone: false,
                    about: event.about
                });
                if (this.fastLoads == 3) {
                    this.fastLoads = 0;
                }
                else {
                    return
                }
            }
            if (event.scale == null) {
                this.ensureTiles(this.currentLevel, event.about);
                return
            }

            let level = this.levelForScale(event.scale);
            let newLevel = Math.max(level, this.minimumLevel);
            if (newLevel != this.currentLevel) {
                if (!currentTiles.keep) {
                    currentTiles.loader.cancel();
                }
                this.hideTilesAboveLevel(newLevel);
                currentTiles = this.ensureTiles(newLevel, event.about);
                this.currentLevel = newLevel;
            } else {
                this.ensureTiles(this.currentLevel, event.about);
            }
            this.bringTilesToFront(currentTiles);
            if (this._foreground) {
                this.addChild(this._foreground);
            }
        }

        /**
       *Activates the textures on the DeepZoomImage.
       *
       * @memberof DeepZoomImage
       */
        activate() {
            this.active = true;
            this.destroyTilesAboveLevel(this.currentLevel);
            this.ensureTiles(this.currentLevel, null);
            //console.log("Activate Textures!", this.currentLevel)
        }

        /**
       *Dectivates the textures on the DeepZoomImage.
       *
       * @memberof DeepZoomImage
       */
        deactivate() {
            this.active = false;
            this.destroyAllTiles();
            this.tileContainer.destroy({ children: true });
        }

        throwFinished() {
            //console.log("throwFinished")
            let key = this.currentLevel.toString();
            let currentTiles = this.tileLayers[key];
            if (typeof currentTiles == 'undefined') {
                return
            }
            this.ensureTiles(this.currentLevel);
        }
    }

    let globalScatterLoaderCanvas = null;

    class ScatterLoader extends CardLoader {
        get scatter() {
            return this.src
        }

        unscaledSize() {
            let displayObject = this.scatter.displayObject;
            let w = displayObject.width;
            let h = displayObject.height;
            return [w / displayObject.scale.x, h / displayObject.scale.y]
        }

        scaledSize() {
            let displayObject = this.scatter.displayObject;
            let w = displayObject.width;
            let h = displayObject.height;
            return [w, h]
        }

        cloneScatterImage() {
            let w = this.scatter.width;
            let h = this.scatter.height;
            let isSprite = this.scatter.displayObject instanceof PIXI.Sprite;
            let isDeepZoom = this.scatter.displayObject instanceof DeepZoomImage;
            let resolution = app.renderer.resolution;
            if (isSprite) {
                w = this.scatter.displayObject.texture.width;
                h = this.scatter.displayObject.texture.height;
            } else if (isDeepZoom) {
                let [ww, hh] = this.scatter.displayObject.baseSize;
                w = ww;
                h = hh;
            }
            if (globalScatterLoaderCanvas === null) {
                globalScatterLoaderCanvas = document.createElement('canvas');
            }
            let canvas = globalScatterLoaderCanvas;
            canvas.width = w;
            canvas.height = h;
            let renderer = new PIXI.WebGLRenderer(w, h, {
                view: canvas,
                resolution: resolution
            });

            let displayObject = this.scatter.displayObject;
            let x = displayObject.x;
            let y = displayObject.y;
            let rot = displayObject.rotation;
            let sx = displayObject.scale.x;
            let sy = displayObject.scale.y;
            displayObject.rotation = 0;
            // The Safari WebGLRenderer wants everything flipped
            // See https://github.com/pixijs/pixi.js/issues/2283
            displayObject.x = 0;
            if (Capabilities.isSafari) {
                displayObject.y = h;
                displayObject.scale.set(1, -1); // sx, -sy)
            } else {
                displayObject.y = 0;
                displayObject.scale.set(1, 1);
            }
            if (isSprite) {
                displayObject.width = w;
                displayObject.height = h;
            }
            renderer.render(displayObject);
            displayObject.rotation = rot;
            displayObject.x = x;
            displayObject.y = y;
            displayObject.scale.set(sx, sy);

            let url = canvas.toDataURL();
            return [x, y, w, h, url]
        }

        load(domNode) {
            return new Promise((resolve, reject) => {
                let isImage = domNode instanceof HTMLImageElement;
                let isSprite = this.scatter.displayObject instanceof PIXI.Sprite;
                let image = isImage ? domNode : document.createElement('img');
                let [x, y, w, h, cloneURL] = this.cloneScatterImage();
                let [ww, hh] = this.unscaledSize();
                image.onload = e => {
                    if (!isImage) domNode.appendChild(image);
                    this.x = x;
                    this.y = y;
                    this.wantedWidth = ww;
                    this.wantedHeight = hh;
                    this.scale = 1;
                    this.rotation = this.scatter.rotation;
                    resolve(this);
                };
                image.onerror = e => {
                    reject(this);
                };
                image.src = cloneURL;
            })
        }
    }

    class FlipEffect {
        constructor(scatter, domScatterContainer, flipTemplate, backLoader) {
            this.flipped = false;
            this.scatter = scatter;
            this.backLoader = backLoader;
            this.scatterLoader = new ScatterLoader(scatter);
            this.domFlip = new DOMFlip(
                domScatterContainer,
                flipTemplate,
                this.scatterLoader,
                backLoader,
                {
                    onBack: this.backCardClosed.bind(this)
                }
            );
            this.setupInfoButton();
        }

        startFlip() {
            let center = this.flipCenter();
            let loader = this.backLoader;
            this.domFlip.load().then(domFlip => {
                this.scatter.displayObject.visible = false;
                domFlip.centerAt(center);
                domFlip.zoom(this.scatter.scale);
                let target = this.constraintFlipCenter(center, loader);
                console.log('FlipEffect.startFlip', target, loader);
                domFlip.start({ targetCenter: target });
            });
        }

        unscaledSize() {
            return this.scatterLoader.unscaledSize()
        }

        flipCenter() {
            let isSprite = this.scatter.displayObject instanceof PIXI.Sprite;
            let resolution = isSprite ? app.renderer.resolution : 1;
            let center = this.scatter.center;
            let canvas = app.renderer.view;
            let domNode = this.domFlip.domScatterContainer.element;
            let page = window.convertPointFromNodeToPage(
                canvas,
                center.x * resolution,
                center.y * resolution
            );
            let local = window.convertPointFromPageToNode(domNode, page.x, page.y);
            return local
        }

        constraintFlipCenter(center, loader) {
            let w = loader.wantedWidth;
            let h = loader.wantedHeight;
            console.log('constraintFlipCenter', w, h);
            let canvas = app.renderer.view;
            let x = center.x;
            let y = center.y;
            if (x < w / 2) x = w / 2;
            if (y < h / 2) y = h / 2;
            if (x > canvas.width) x = canvas.width - w / 2;
            if (y > canvas.height) y = canvas.height - h / 2;
            return { x, y }
        }

        setupInfoButton() {
            let iscale = 1.0 / this.scatter.scale;
            this.infoBtn = new PIXI.Graphics();
            this.infoBtn.beginFill(0x333333);
            this.infoBtn.lineStyle(4, 0xffffff);
            this.infoBtn.drawCircle(0, 0, 22);
            this.infoBtn.endFill();

            this.infoBtn.beginFill(0xffffff);
            this.infoBtn.lineStyle(0);
            this.infoBtn.drawCircle(0, -8, 4);
            this.infoBtn.endFill();

            this.infoBtn.lineStyle(6, 0xffffff);
            this.infoBtn.moveTo(0, -2);
            this.infoBtn.lineTo(0, 14);
            this.infoBtn.endFill();

            this.infoBtn.on('click', e => {
                this.infoSelected();
            });
            this.infoBtn.on('tap', e => {
                this.infoSelected();
            });

            this.infoBtn.interactive = true;
            this.infoBtn.width = 44;
            this.infoBtn.height = 44;
            this.infoBtn.pivot.x = 30;
            this.infoBtn.pivot.y = 30;

            let displayObject = this.scatter.displayObject;
            let [w, h] = this.unscaledSize();
            this.infoBtn.position = new PIXI.Point(w, h);
            if (displayObject.foreground) {
                this.infoBtn.scale.x = iscale;
                this.infoBtn.scale.y = iscale;
                displayObject.foreground.addChild(this.infoBtn);
            } else {
                displayObject.addChild(this.infoBtn);
            }

            this.scatter.addTransformEventCallback(e => {
                let displayObject = this.scatter.displayObject;
                if (displayObject.foreground) {
                    if (e.scale) {
                        let iscale = 1.0 / e.scale;
                        this.infoBtn.scale.x = iscale;
                        this.infoBtn.scale.y = iscale;
                    }
                }
            });
        }

        setupButton(url) {
            let svgImage = new Image();
            let canvas = document.createElement('canvas');
            canvas.width = 88 * 4;
            canvas.height = 44 * 4;
            svgImage.onload = e => {
                let displayObject = this.scatter.displayObject;
                canvas
                    .getContext('2d')
                    .drawImage(svgImage, 0, 0, canvas.width, canvas.height);
                let texure = new PIXI.Texture(new PIXI.BaseTexture(canvas));
                this.infoBtn = new PIXI.Sprite(texure);
                this.infoBtn.anchor.set(0.5, 0.5);
                if (displayObject.foreground) {
                    displayObject.foreground.addChild(this.infoBtn);
                } else {
                    displayObject.addChild(this.infoBtn);
                }
                this.infoBtn.scale.set(0.5, 0.5);

                let [w, h] = this.unscaledSize();
                this.infoBtn.position = new PIXI.Point(w, h);
                this.infoBtn.interactive = true;
                this.infoBtn.updateTransform();
                this.infoBtn.on('click', e => {
                    this.infoSelected();
                });
                this.infoBtn.on('tap', e => {
                    this.infoSelected();
                });
            };
            svgImage.src = url;
        }

        infoSelected() {
            this.startFlip();
        }

        backSelected() {
            this.domFlip.start();
        }

        backCardClosed() {
            /*** The flip effect should now be in it's initial state again. All
            memory should be freed. ***/
            let displayObject = this.scatter.displayObject;
            displayObject.visible = true;
            this.domFlip.fadeOutAndRemove();
            this.flipped = false;
        }

        targetRotation(alpha) {
            let ortho = 90;
            let rest = alpha % ortho;
            let delta = 0.0;
            if (rest > ortho / 2.0) {
                delta = ortho - rest;
            } else {
                delta = -rest;
            }
            return delta
        }
    }

    /* globals Power2, Sine */
    /*eslint no-console: ["error", { allow: ["log", "info", "error"] }]*/

    /**
     * Callback for the flippable onStart function.
     *
     * @callback onStartCallback
     * @param {Flippable} flippable - A reference to the flippable (also this refers to the flippable).
     */

    /**
     * Callback for the flippable onUpdate function.
     *
     * @callback onUpdateCallback
     * @param {Flippable} flippable - A reference to the flippable (also this refers to the flippable).
     */

    /**
     * Callback for the flippable onComplete function.
     *
     * @callback onCompleteCallback
     * @param {Flippable} flippable - A reference to the flippable (also this refers to the flippable).
     */

    /**
     * Class that represents a PixiJS Flippable.
     *
     * @example
     * const front = PIXI.Sprite.fromImage('./assets/front.jpg')
     * const back = PIXI.Sprite.fromImage('./assets/back.jpg')
     * app.scene.addChild(front)
     *
     * // Create the flippable
     * const flippable = new Flippable(front, back, app.renderer)
     *
     * front.interactive = true
     * front.on('click', event => flippable.toggle())
     *
     * @class
     * @extends PIXI.projection.Camera3d
     * @see {@link https://github.com/pixijs/pixi-projection|PixiJS Projection}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/flippable.html|DocTest}
     */
    class Flippable extends PIXI.projection.Camera3d {
        /**
         * Creates an instance of a Flippable.
         *
         * @constructor
         * @param {PIXI.DisplayObject} front - The object to show initially. Should have been added to the scene.
         * @param {PIXI.DisplayObject} back - The object to show on the backside. Should have not been added to the scene.
         * @param {PIXI.WebGLRenderer|PIXI.CanvasRenderer} renderer - The renderer of the application.
         * @param {object} [opts] - An options object which can contain the following properties.
         * @param {number} [opts.duration=1] - The duration of the flip animation in seconds.
         * @param {GSAP.Ease} [opts.ease=Power2.easeOut] - The ease of the flip animation.
         * @param {boolean} [opts.shadow=false] - Should be a shadow been display during the animation?
         * @param {numer} [opts.eulerX=0] - The shift of the x-axis during the animation.
         * @param {numer} [opts.eulerY=0] - The shift of the y-axis during the animation.
         * @param {GSAP.Ease} [opts.eulerEase=Power1.easeOut] - The ease of the shift.
         * @param {boolean} [opts.useBackTransforms=false] - When set to true, the flip animation also animates to the transform parameters of the back-object.
         * @param {GSAP.Ease} [opts.transformEase=Power2.easeOut] - The ease of the transform.
         * @param {numer} [opts.focus=800] - The value of the focus of the 3D camera (see pixi-projection).
         * @param {numer} [opts.near=10] - The near value of the 3D camera (see pixi-projection).
         * @param {numer} [opts.far=10000] - The far value of the 3D camera (see pixi-projection).
         * @param {boolean} [opts.orthographic=false] - Should the flip animation be an orthographic animation?
         * @param {function} [opts.onStart=null] - A callback executed on start of the flip animation.
         * @param {function} [opts.onUpdate=null] - A callback executed on each step of the flip animation.
         * @param {function} [opts.onComplete=null] - A callback executed when the flip animation is finished.
         */
        constructor(front, back, renderer, opts = {}) {
            super();

            this.opts = Object.assign(
                {},
                {
                    front,
                    back,
                    renderer,
                    duration: 1,
                    ease: Power2.easeOut,
                    shadow: false,
                    eulerX: 0,
                    eulerY: 0,
                    eulerEase: Power1.easeOut,
                    useBackTransforms: false,
                    transformEase: Power2.easeOut,
                    focus: 800,
                    near: 10,
                    far: 10000,
                    orthographic: false
                },
                opts
            );

            // planes
            //--------------------
            this.setPlanes(
                this.opts.focus,
                this.opts.near,
                this.opts.far,
                this.opts.orthographic
            );

            // flipped
            //--------------------
            this._flipped = false;

            // objects
            //--------------------
            this.objects = {};

            // setup
            //--------------------
            this.setup();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Flippable} A reference to the flippable for chaining.
         */
        setup() {
            const scale = 0.5;

            // filters
            //--------------------
            const blurFilter = new PIXI.filters.BlurFilter();
            blurFilter.blur = 0.2;
            this.objects.blurFilter = blurFilter;

            // outer
            //--------------------
            const outer = new PIXI.projection.Container3d();
            outer.scale3d.set(scale);
            this.addChild(outer);
            this.objects.outer = outer;

            // shadow
            //--------------------
            const shadow = new PIXI.projection.Sprite3d(
                PIXI.Texture.fromImage('../../assets/images/shadow.png')
            );
            shadow.renderable = false;
            shadow.anchor.set(0.5);
            shadow.scale3d.set(0.98);
            shadow.alpha = 0.7;
            shadow.filters = [blurFilter];
            shadow.visible = this.opts.shadow;
            outer.addChild(shadow);
            this.objects.shadow = shadow;

            // inner
            //--------------------
            const inner = new PIXI.projection.Container3d();
            inner.euler.y = Math.PI;
            outer.addChild(inner);
            this.objects.inner = inner;

            // front
            //--------------------
            const front = new PIXI.projection.Sprite3d(PIXI.Texture.EMPTY);
            front.scale.set(-1 / scale, 1 / scale);
            front.renderable = true;
            front.anchor.set(0.5);
            inner.addChild(front);
            this.objects.front = front;

            // back
            //--------------------
            const back = new PIXI.projection.Sprite3d(PIXI.Texture.EMPTY);
            back.scale.set(1 / scale, 1 / scale);
            back.renderable = false;
            back.anchor.set(0.5);
            inner.addChild(back);
            this.objects.back = back;

            return this
        }

        /**
         * Gets or sets the flipped state.
         *
         * @member {boolean}
         */
        get flipped() {
            return this._flipped
        }
        set flipped(toBack) {
            this._flipped = toBack;

            // references
            //--------------------
            const front = this.objects.front;
            const back = this.objects.back;
            const inner = this.objects.inner;
            const shadow = this.objects.shadow;
            const blurFilter = this.objects.blurFilter;

            const half = this.opts.duration / 2;
            const ease = this.opts.eulerEase;

            const fromObject = toBack ? this.opts.front : this.opts.back;
            const toObject = toBack ? this.opts.back : this.opts.front;

            // set textures for virtual front and virtual back
            //--------------------
            front.texture = this.generateTexture(this.opts.front);
            back.texture = this.generateTexture(this.opts.back);

            // switch objects and set params for virtual objects
            //--------------------
            const fromCenter = this.anchorToCenter(fromObject);
            const toCenter = this.anchorToCenter(toObject);

            // from values
            //--------------------
            this.x = fromCenter.x;
            this.y = fromCenter.y;
            front.width = fromObject.width * 2;
            front.height = fromObject.height * 2;
            back.width = fromObject.width * 2;
            back.height = fromObject.height * 2;
            this.rotation = fromObject.rotation;
            this.skew.x = fromObject.skew.x;
            this.skew.y = fromObject.skew.y;

            // calculate to values
            //--------------------
            const to = {
                x: this.opts.useBackTransforms ? toCenter.x : fromCenter.x,
                y: this.opts.useBackTransforms ? toCenter.y : fromCenter.y,
                anchorX: this.opts.useBackTransforms ? toObject.x : fromObject.x,
                anchorY: this.opts.useBackTransforms ? toObject.y : fromObject.y,
                width: this.opts.useBackTransforms
                    ? toObject.width * 2
                    : fromObject.width * 2,
                height: this.opts.useBackTransforms
                    ? toObject.height * 2
                    : fromObject.height * 2,
                rotation: this.opts.useBackTransforms
                    ? toObject.rotation
                    : fromObject.rotation,
                skewX: this.opts.useBackTransforms
                    ? toObject.skew.x
                    : fromObject.skew.x,
                skewY: this.opts.useBackTransforms
                    ? toObject.skew.y
                    : fromObject.skew.y
            };

            // set toObject end values
            //--------------------
            toObject.x = to.anchorX;
            toObject.y = to.anchorY;
            toObject.width = to.width / 2;
            toObject.height = to.height / 2;
            toObject.rotation = to.rotation;
            toObject.skew.x = to.skewX;
            toObject.skew.y = to.skewY;

            // flip
            //--------------------
            TweenLite.to(inner.euler, this.opts.duration, {
                y: toBack ? 0 : Math.PI,
                ease: this.opts.ease,
                onStart: () => {
                    this.switchDisplayObject(fromObject, this);
                    shadow.renderable = true;
                    if (this.opts.onStart) {
                        this.opts.onStart(this, this);
                    }
                },
                onUpdate: () => {
                    this.layout();
                    if (this.opts.onUpdate) {
                        this.opts.onUpdate(this, this);
                    }
                },
                onComplete: () => {
                    this.switchDisplayObject(this, toObject);
                    shadow.renderable = false;
                    if (this.opts.onComplete) {
                        this.opts.onComplete(this, this);
                    }
                }
            });

            // x & y
            //--------------------
            TweenLite.to(this, this.opts.duration, {
                x: to.x,
                y: to.y,
                ease: this.opts.transformEase
            });

            // width & height
            //--------------------
            TweenLite.to([front, back], this.opts.duration, {
                width: to.width,
                height: to.height,
                ease: this.opts.transformEase
            });

            // rotation
            //--------------------
            TweenLite.to(this, this.opts.duration, {
                directionalRotation: {
                    rotation: `${to.rotation}_short`,
                    useRadians: true
                },
                ease: this.opts.transformEase
            });

            // skewX & skewY
            //--------------------
            TweenLite.to(this.skew, this.opts.duration, {
                x: to.skewX,
                y: to.skewY,
                ease: this.opts.transformEase
            });

            // camera
            //--------------------
            new TimelineMax()
                .to(this.euler, half, {
                    x: this.opts.eulerX,
                    y: this.opts.eulerY,
                    ease
                })
                .to(this.euler, half, { x: 0, y: 0, ease });

            // shadow
            //--------------------
            new TimelineMax()
                .to(shadow, half, { alpha: 0.3, ease })
                .to(shadow, half, { alpha: 0.7, ease });

            // blurfilter
            //--------------------
            new TimelineMax()
                .to(blurFilter, half, { blur: 6, ease })
                .to(blurFilter, half, { blur: 0.2, ease });
        }

        /**
         * Should be called to refresh the layout of the camera.
         *
         * @return {Flippable} A reference to the flippable for chaining.
         */
        layout() {
            const front = this.objects.front;
            const back = this.objects.back;
            const shadow = this.objects.shadow;
            const inner = this.objects.inner;

            inner.position3d.z =
                -Math.sin(inner.euler.y) * front.texture.baseTexture.width * 2;

            //this.objects.shadow.euler = this.objects.inner.euler
            shadow.euler.x = -inner.euler.x;
            shadow.euler.y = -inner.euler.y;

            if (this.frontSideInFront) {
                front.renderable = true;
                back.renderable = false;
                shadow.width = front.width;
                shadow.height = front.height;
            } else {
                front.renderable = false;
                back.renderable = true;
                shadow.width = back.width;
                shadow.height = back.height;
            }

            return this
        }

        /**
         * Toggles the flippable. Switches the sides.
         *
         * @private
         * @return {Flippable} A reference to the flippable for chaining.
         */
        toggle() {
            this.flipped = !this.flipped;

            return this
        }

        /**
         * Gets the alignment state. true if the front side is in front, false otherwise.
         *
         * @member {boolean}
         */
        get frontSideInFront() {
            return !this.objects.inner.isFrontFace()
        }

        /**
         * Calculates the center point of an PIXI.DisplayObject.
         *
         * @private
         * @param {PIXI.DisplayObject} displayObject - The DisplayObject from which to calculate the center.
         * @return {object} Return an object with x and y.
         */
        anchorToCenter(displayObject) {
            const bounds = displayObject.getBounds();
            return {
                x: bounds.x + bounds.width / 2,
                y: bounds.y + bounds.height / 2
            }
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @param {PIXI.DisplayObject} displayObject - The DisplayObject from which to generate the texture.
         * @return {PIXI.Texture} The generated PIXI.Texture.
         */
        generateTexture(displayObject) {
            // renderTexture
            //--------------------
            const renderTexture = PIXI.RenderTexture.create(
                displayObject.width,
                displayObject.height
            );

            // save position
            const transform = [
                displayObject.x,
                displayObject.y,
                displayObject.scale.x,
                displayObject.scale.y,
                displayObject.rotation,
                displayObject.skew.x,
                displayObject.skew.y,
                displayObject.pivot.x,
                displayObject.pivot.y
            ];

            displayObject.position.set(0, 0);
            displayObject.skew.set(0, 0);
            displayObject.rotation = 0;

            // render
            //--------------------
            this.opts.renderer.render(displayObject, renderTexture);

            // restore position
            displayObject.setTransform(...transform);

            return renderTexture
        }

        /**
         * Removed the first DisplayObject and adds the second one at the exactly same position.
         *
         * @private
         * @param {PIXI.DisplayObject} first - The old DisplayObject.
         * @param {PIXI.DisplayObject} second - The new DisplayObject.
         * @return {Flippable} A reference to the flippable for chaining.
         */
        switchDisplayObject(first, second) {
            if (first && second && first.parent) {
                const parent = first.parent;
                const index = parent.getChildIndex(first);
                parent.addChildAt(second, index);
                parent.removeChild(first);
            }

            return this
        }
    }

    /**
     *
     */
    class Popover extends PIXI.Graphics {
        constructor({
            title = null,
            text = null,
            x = 0,
            y = 0,
            placement = 'top',
            width = 250,
            titleStyle = {},
            textStyle = { fontSize: '1.6em' }
        } = {}) {
            super();

            this.opts = {
                title,
                text,
                x,
                y,
                placement,
                width,
                titleStyle,
                textStyle
            };

            this.padding = 12;

            let style = {
                fontFamily: 'Arial',
                fontSize: '2em',
                stroke: '#f6f6f6',
                strokeThickness: 3,
                wordWrap: true,
                wordWrapWidth: width - this.padding * 2
            };

            this.titleTextStyle = new PIXI.TextStyle(
                Object.assign({}, style, titleStyle)
            );
            this.textTextStyle = new PIXI.TextStyle(
                Object.assign({}, style, textStyle)
            );

            if (title || text) {
                this.setup();
                this.draw();
                this.positioning();
            }
        }

        setup() {
            this.removeChildren();

            if (this.opts.title) {
                this.titleText = new PIXI.Text(this.opts.title, this.titleTextStyle);
                this.titleText.position.set(this.padding, this.padding);
                this.addChild(this.titleText);
            }

            this.titleY = this.titleText ? this.titleText.y : 0;
            this.titleHeight = this.titleText ? this.titleText.height : 0;

            if (this.opts.text) {
                this.textText = new PIXI.Text(this.opts.text, this.textTextStyle);
                this.textText.position.set(
                    this.padding,
                    this.titleY + this.titleHeight + this.padding
                );
                this.addChild(this.textText);
            }

            this.textY = this.textText ? this.textText.y : 0;
            this.textHeight = this.textText ? this.textText.height : 0;
        }

        close() {
            this.parent.removeChild(this);
        }

        draw() {
            this.clear();
            this.beginFill(0xffffff, 1);
            this.lineStyle(1, 0x282828, 0.5);

            // Draw rounded rectangle
            const height = this.height + this.padding;
            this.drawRoundedRect(0, 0, this.opts.width, height, 8);

            // Draw anchor
            this.drawAnchor(this.opts.placement);

            // Draw title background
            if (this.opts.title) {
                this.lineStyle(0);
                this.beginFill(0xf7f7f7, 1);
                let x = 1;
                let y = this.titleText.x + this.titleText.height + this.padding / 2;
                this.moveTo(x, y);
                y = 9;
                this.lineTo(x, y);
                this.quadraticCurveTo(x, y - 8, x + 8, y - 8);
                x += this.opts.width - 7;
                y -= 8;
                this.lineTo(x, y);
                this.quadraticCurveTo(x + 5, y, x + 5, y + 8);
                x += 5;
                y += this.titleText.x + this.titleText.height + this.padding / 2;
                this.lineTo(x, y);
                if (this.opts.text) {
                    x = 1;
                    this.lineTo(x, y);
                } else {
                    this.quadraticCurveTo(x, y, x - 5, y + 4);
                    x = 6;
                    y += 4;
                    this.lineTo(x, y);
                    this.quadraticCurveTo(x, y, x - 5, y - 4);
                }
            }

            this.endFill();
        }

        drawAnchor(placement) {
            let x = 0;
            let y = 0;

            switch (placement) {
                case 'bottom':
                    if (this.opts.title) {
                        this.beginFill(0xf7f7f7, 1);
                    }
                    x = this.width / 2 - 10;
                    y = 1;
                    this.moveTo(x, y);
                    x += 10;
                    y -= 10;
                    this.lineTo(x, y);
                    x += 10;
                    y += 10;
                    this.lineTo(x, y);
                    break
                case 'right':
                    x = 1;
                    y = this.height / 2 - 10;
                    if (this.titleY + this.titleHeight > y) {
                        this.beginFill(0xf7f7f7, 1);
                    }
                    this.moveTo(x, y);
                    x -= 10;
                    y += 10;
                    this.lineTo(x, y);
                    x += 10;
                    y += 10;
                    this.lineTo(x, y);
                    break
                case 'left':
                    x = this.width - 2;
                    y = this.height / 2 - 10;
                    if (this.titleY + this.titleHeight > y) {
                        this.beginFill(0xf7f7f7, 1);
                    }
                    this.moveTo(x, y);
                    x += 10;
                    y += 10;
                    this.lineTo(x, y);
                    x -= 10;
                    y += 10;
                    this.lineTo(x, y);
                    break
                default:
                    x = this.width / 2 - 10;
                    y = this.height - 2;
                    this.moveTo(x, y);
                    x += 10;
                    y += 10;
                    this.lineTo(x, y);
                    x += 10;
                    y -= 10;
                    this.lineTo(x, y);
                    break
            }
        }

        positioning() {
            const x = this.opts.x;
            const y = this.opts.y;

            switch (this.opts.placement) {
                case 'bottom':
                    this.position.set(x - this.width / 2, y + 10);
                    break
                case 'right':
                    this.position.set(x, y - this.height / 2);
                    break
                case 'left':
                    this.position.set(x - this.width, y - this.height / 2);
                    break
                default:
                    this.position.set(x - this.width / 2, y - this.height);
                    break
            }
        }
    }

    /** A container for scatter objects, which uses a single InteractionMapper
     * for all children. This reduces the number of registered event handlers
     * and covers the common use case that multiple objects are scattered
     * on the same level.
     */
    class ScatterContainer extends PIXI.Graphics {
        /**
         * @constructor
         * @param {PIXI.Renderer} renderer - PIXI renderer, needed for hit testing
         * @param {Bool} stopEvents - Whether events should be stopped or propagated
         * @param {Bool} claimEvents - Whether events should be marked as claimed
         *                             if findTarget return as non-null value.
         * @param {PIXI.Container} container - A container for the scatter
         * @param {Bool} showBounds - Show bounds for debugging purposes.
         * @param {Bool} showTouches - Show touches and pointer for debugging purposes.
         * @param {Color} backgroundColor - Set background color if specified.
         * @param {PIXIApp} app - Needed if showBounds is true to register
         *                                update handler.
         */
        constructor(
            renderer,
            {
                stopEvents = true,
                claimEvents = true,
                container = null,
                showBounds = false,
                showPolygon = false,
                showTouches = false,
                backgroundColor = null,
                app = window.app
            } = {}
        ) {
            super();
            this.container = container;
            if (this.container)
                this.containerDimensions = {
                    x: this.container.width,
                    y: this.container.height
                };
            this.backgroundWidth = null;
            this.backgroundHeight = null;
            this.app = app;
            this.renderer = renderer;
            this.stopEvents = stopEvents;
            this.claimEvents = claimEvents;
            this.delegate = new InteractionMapper$1(this.eventReceiver, this);
            this.showBounds = showBounds;
            this.showTouches = showTouches;
            this.showPolygon = showPolygon;
            this.backgroundColor = backgroundColor;
            if (showBounds || showTouches || showPolygon) {
                //console.log("Show TOUCHES!!!")
                this.app.ticker.add(delta => this.update(delta), this);
            }
            if (backgroundColor) {
                this.updateBackground();
            }
        }

        updateBackground() {
            this.clear();
            let rect = this.bounds;
            this.beginFill(this.backgroundColor, 1);
            this.drawRect(0, 0, rect.width, rect.height);
            this.endFill();
        }

        get eventReceiver() {
            return this.renderer.plugins.interaction.interactionDOMElement
        }

        get bounds() {
            let x = 0;
            let y = 0;
            // @container: We need to call the constant values, as the container
            // gets resized, when a child moves outside the original boundaries.
            let w = this.container
                ? this.containerDimensions.x
                : this.backgroundWidth || this.app.width;
            let h = this.container
                ? this.containerDimensions.y
                : this.backgroundHeight || this.app.height;

            if (this.app.fullscreen && this.app.monkeyPatchMapping) {
                let fixed = this.mapPositionToPoint({ x: w, y: 0 });
                if (fixed.x < w) {
                    w = fixed.x;
                }
                if (fixed.y > 0) {
                    y += fixed.y;
                    h -= fixed.y;
                }
            }
            return new PIXI.Rectangle(x, y, w, h)
        }

        get center() {
            let r = this.bounds;
            return { x: r.width / 2, y: r.height / 2 }
        }

        get polygon() {
            let r = this.bounds;
            let w2 = r.width / 2;
            let h2 = r.height / 2;
            let center = { x: w2, y: h2 };
            let polygon = new Polygon(center);
            polygon.addPoint({ x: -w2, y: -h2 });
            polygon.addPoint({ x: w2, y: -h2 });
            polygon.addPoint({ x: w2, y: h2 });
            polygon.addPoint({ x: -w2, y: h2 });
            return polygon
        }

        update(dt) {
            this.clear();
            this.lineStyle(1, 0x0000ff);
            if (this.showBounds) {
                for (let child of this.children) {
                    if (child.scatter) {
                        //let {x, y, width, height} = child.scatter.throwBounds()
                        // new PIXI.Rectangle(x, y, width, height)
                        this.drawShape(child.scatter.bounds);
                        let center = child.scatter.center;
                        this.drawCircle(center.x, center.y, 4);
                        this.drawCircle(child.scatter.x, child.scatter.y, 4);
                    }
                }
                this.lineStyle(2, 0x0000ff);
                this.drawShape(this.bounds);
            }
            if (this.showPolygon) {
                this.lineStyle(2, 0xff0000);
                for (let child of this.children) {
                    if (child.scatter) {
                        let polygon = child.scatter.polygon;
                        let shape = new PIXI.Polygon(polygon.flatAbsolutePoints());
                        shape.close();
                        this.drawShape(shape);
                    }
                }
            }
            if (this.showTouches) {
                let current = this.delegate.interaction.current;
                for (let [key, point] of current.entries()) {
                    let local = this.mapPositionToPoint(point);
                    this.drawCircle(local.x, local.y, 12);
                }
            }
        }

        capture(event) {
            if (this.stopEvents) Events$1.stop(event);
            return true
        }

        fakeInteractionEvent(point, key) {
            return { data: { global: point, key: key } }
        }

        findHitScatter(data, displayObject, hit) {
            //     if (hit) {
            //             console.log("findHitScatter", displayObject)
            //         }
            if (
                hit &&
                this.hitScatter === null &&
                typeof displayObject != undefined
            ) {
                this.hitScatter = displayObject.scatter
                    ? displayObject.scatter
                    : null;
            }
        }

        mapPositionToPoint(point, element = null) {
            // In case of nested scatters we get an additional parameter that
            // contains the found scatter
            let local = new PIXI.Point();
            let interactionManager = this.renderer.plugins.interaction;
            interactionManager.mapPositionToPoint(local, point.x, point.y);
            if (
                element instanceof DisplayObjectScatter &&
                element.displayObject.parent != null
            ) {
                return element.displayObject.parent.toLocal(local)
            }
            return local
        }

        /**
         * New method hitTest implemented (in InteractionManager, since 4.5.0).
         * See https://github.com/pixijs/pixi.js/pull/3878
         */
        findTarget(event, local, global) {
            if (event.claimedByScatter) {
                return null
            }
            this.hitScatter = null;
            let interactionManager = this.renderer.plugins.interaction;
            let fakeEvent = this.fakeInteractionEvent(local);
            interactionManager.processInteractive(
                fakeEvent,
                this,
                this.findHitScatter.bind(this),
                true
            );
            if (this.claimEvents) event.claimedByScatter = this.hitScatter;
            return this.hitScatter
        }

        findTargetNew(event, local, global) {
            // UO: still problematic. Does not find non interactive elements
            // which are needed for some stylus applications
            if (event.claimedByScatter) {
                return null
            }
            this.hitScatter = null;
            let interactionManager = this.renderer.plugins.interaction;
            let displayObject = interactionManager.hitTest(local, this);
            if (displayObject != null && displayObject.scatter != null)
                this.hitScatter = displayObject.scatter;
            if (this.claimEvents) event.claimedByScatter = this.hitScatter;
            return this.hitScatter
        }

        onStart(event, interaction) {}

        onMove(event, interaction) {}

        onEnd(event, interaction) {
            for (let key of interaction.ended.keys()) {
                let point = interaction.ended.get(key);
                if (interaction.isLongPress(key)) {
                    this.onLongPress(key, point);
                }
                if (interaction.isTap(key)) {
                    this.onTap(key, point);
                }
            }
        }

        onTap(key, point) {
            console.info('ScatterContainer.onTap');
        }

        onLongPress(key, point) {
            console.info('ScatterContainer.onLongPress');
        }

        bringToFront(displayObject) {
            this.addChild(displayObject);
        }

        layout(width, height) {
            this.backgroundWidth = width;
            this.backgroundHeight = height;
            if (this.backgroundColor) {
                this.updateBackground();
            }
        }
    }

    /** A wrapper for child elements of a ScatterContainer. Can be used
     *  to combine scattered objects with non-scattered objects. Any
     *  PIXI.DisplayObject can be wrapped.
     */
    class DisplayObjectScatter extends AbstractScatter {
        constructor(
            displayObject,
            renderer,
            {
                x = null,
                y = null,
                minScale = 0.1,
                maxScale = 1.0,
                startScale = 1.0,
                autoBringToFront = true,
                translatable = true,
                scalable = true,
                rotatable = true,
                resizable = false,
                movableX = true,
                movableY = true,
                throwVisibility = 44,
                throwDamping = 0.95,
                autoThrow = true,
                rotationDegrees = null,
                rotation = null,
                overdoScaling = 1.5,
                onTransform = null,
                onThrowFinished = null
            } = {}
        ) {
            // For the simulation of named parameters,
            // see: http://exploringjs.com/es6/ch_parameter-handling.html
            super({
                overdoScaling,
                minScale,
                maxScale,
                startScale,
                autoBringToFront,
                translatable,
                scalable,
                rotatable,
                resizable,
                movableX,
                movableY,
                throwVisibility,
                throwDamping,
                autoThrow,
                onThrowFinished,
                rotationDegrees,
                rotation,
                onTransform
            });
            this.displayObject = displayObject;
            this.displayObject.scatter = this;
            this.renderer = renderer;
            this.scale = startScale;
            this.rotationDegrees = this.startRotationDegrees;

            // Only set x and y if they are specified.
            // Otherwise the displayobject gets corrupted.
            if (x != null) this.x = x;
            if (y != null) this.y = y;
        }

        getWorldScatter() {
            return this
        }

        /** Returns geometry data as object. **/
        getState() {
            return {
                scale: this.scale,
                x: this.x,
                y: this.y,
                rotation: this.rotation
            }
        }

        setup() {
            this.setupMouseWheelInteraction();
        }

        roundPixel(value) {
            // UO: Should be obsolete because Renderer supports roundPixels by default
            return value
            let res = this.renderer.resolution;
            return Math.round(value * res) / res
        }

        get container() {
            // return this.displayObject.parent
            let obj = this.displayObject;
            while (obj.parent != null && !(obj.parent instanceof ScatterContainer))
                obj = obj.parent;
            return obj.parent
        }

        get x() {
            return this.position.x
        }

        set x(value) {
            this.position.x = value;
        }

        get y() {
            return this.position.y
        }

        set y(value) {
            this.position.y = value;
        }

        get polygon() {
            let polygon = new Polygon(this.center);
            let w2 = this.width / 2;
            let h2 = this.height / 2;
            polygon.addPoint({ x: -w2, y: -h2 });
            polygon.addPoint({ x: w2, y: -h2 });
            polygon.addPoint({ x: w2, y: h2 });
            polygon.addPoint({ x: -w2, y: h2 });
            polygon.rotate(this.rotation);
            return polygon
        }

        get containerBounds() {
            return this.container.bounds
        }

        get containerPolygon() {
            let container = this.container;
            if (container == null) return null
            return container.polygon
        }

        get position() {
            return this.displayObject.position
        }

        set position(value) {
            this.displayObject.position = value;
        }

        get scale() {
            return this.displayObject.scale.x
        }

        set scale(value) {
            this.displayObject.scale.x = value;
            this.displayObject.scale.y = value;
        }

        get width() {
            return this.displayObject.width
        }

        get height() {
            return this.displayObject.height
        }

        get bounds() {
            return this.displayObject.getBounds()
        }

        get pivot() {
            return this.displayObject.pivot
        }

        get rotation() {
            return this.displayObject.rotation
        }

        set rotation(value) {
            this.displayObject.rotation = value;
        }

        get rotationDegrees() {
            return Angle.radian2degree(this.displayObject.rotation)
        }

        set rotationDegrees(value) {
            this.displayObject.rotation = Angle.degree2radian(value);
        }

        get center() {
            let w2 = this.width / 2;
            let h2 = this.height / 2;
            let dist = Math.sqrt(w2 * w2 + h2 * h2);
            let angle = Points.angle({ x: w2, y: h2 }, { x: 0, y: 0 });
            let p = this.displayObject.x;
            let c = Points.arc(this.position, this.rotation + angle, dist);
            return c // Points.subtract(c, this.pivot)
        }

        get rotationOrigin() {
            // In PIXI the default rotation and scale origin is the position
            return this.position // Points.add(this.position, this.pivot)
        }

        mapPositionToContainerPoint(point) {
            // UO: We need the coordinates related to this scatter in case
            // of nested scatters
            if (this.container != null)
                return this.container.mapPositionToPoint(point, this)
            return point
        }

        capture(event) {
            return true
        }

        bringToFront() {
            if (this.autoBringToFront) {
                if (this.displayObject.parent instanceof ScatterContainer) {
                    let scatterContainer = this.displayObject.parent;
                    scatterContainer.bringToFront(this.displayObject);
                } else if (
                    this.displayObject.parent != null &&
                    this.displayObject.parent.scatter
                ) {
                    this.displayObject.parent.scatter.toFront(this.displayObject);
                }
            }
        }

        toFront(displayObject) {
            this.displayObject.addChild(displayObject);
        }

        validScale(scale) {
            scale = Math.max(scale, this.minScale);
            scale = Math.min(scale, this.maxScale);
            return scale
        }
    }

    /**
     *
     */
    class Command extends PIXI.Graphics {
        /*** Abstract base class for record, play, and stop commands. ***/
        constructor(tools, selectedColor, shape) {
            super();
            this.tools = tools;
            this.shape = shape;
            this.selected = false;
            this.disabled = false;
            this.selectedColor = selectedColor;
            this.draw();
            this.setup();
        }

        setup() {}

        draw() {
            this.clear();
            var color = this.selected ? this.selectedColor : 0xffffff;
            this.lineStyle(0);
            this.beginFill(color, 1);
            this.drawShape(this.shape);
            this.endFill();
        }

        select() {
            this.selected = true;
            this.draw();
        }

        deselect() {
            this.selected = false;
            this.draw();
        }

        toggle() {
            this.selected = !this.selected;
            this.draw();
        }

        stop() {
            this.selected = false;
            this.draw();
        }
    }

    class RecordCommand extends Command {
        /*** Records events for later replay. ***/
        setup() {
            this.recorder = new EventRecorder();
        }

        toggle() {
            super.toggle();
            if (!this.selected) {
                this.recorder.stopRecording();
            }
        }

        recordEvent(event) {
            this.recorder.record(event);
        }

        normalize(value, limit) {
            return value / limit
        }

        normalizeX(value) {
            return this.normalize(value, window.innerWidth)
        }

        normalizeY(value) {
            return this.normalize(value, window.innerHeight)
        }

        whileNotStopped() {
            return this.tools.play.selected
        }

        startReplay() {
            let whileCondition = this.whileNotStopped.bind(this);
            this.recorder.startReplay(whileCondition, () => this.tools.play.stop());
        }
    }

    class PlayCommand extends Command {
        /*** Plays recorded events. ***/
        toggle() {
            super.toggle();
            if (this.selected && this.tools.record.recorder.recorded.length > 0) {
                this.tools.startReplay();
            }
        }
    }

    class StopCommand extends Command {
        /*** Stops recording and playing. ***/
        toggle() {
            super.toggle();
            this.tools.record.stop();
            this.tools.play.stop();
            setTimeout(this.deselect.bind(this), 500);
        }
    }

    class RecorderTools extends PIXI.Container {
        constructor(renderer) {
            super(renderer);
            this.renderer = renderer;
            this.setupToolbar();
            this.replayRate = 100.0;
            this.onReset = null;
            this.touchGraphics = new PIXI.Graphics();
            this.showTouches();
        }

        setup(container) {
            // Since this delegate might shadow another delegate, we mus avoid
            // capturing PointerEvents.
            this.delegate = new InteractionMapper(container, this, {
                capturePointerEvents: false
            });
        }

        findTarget(event, local, global) {
            return this
        }

        setupToolbar() {
            this.toolbar = new PIXI.Graphics();
            this.record = new RecordCommand(
                this,
                0xcc0000,
                new PIXI.Circle(0, 0, 16)
            );
            this.play = new PlayCommand(
                this,
                0x0000cc,
                new PIXI.Polygon(0, 16, 32, 16 + 16, 0, 16 + 32, 0, 16)
            );
            this.stop = new StopCommand(
                this,
                0x0000cc,
                new PIXI.Rectangle(0, 0, 32, 32)
            );
            this.toolbar.addChild(this.record).position.set(44, 48);
            this.toolbar.addChild(this.play).position.set(44 + 44, 16);
            this.toolbar.addChild(this.stop).position.set(44 + 44 + 44 + 16, 32);
            this.updateToolbar();
            this.addChild(this.toolbar);
        }

        updateToolbar() {
            var graphics = this.toolbar;
            graphics.clear();
            graphics.beginFill(0x000000, 0.5);
            graphics.lineStyle(2, 0xffffff, 1);
            graphics.drawRoundedRect(16, 16, 44 * 4 + 8, 64, 8);
            graphics.endFill();
        }

        onMouseWheel(event) {
            console.log('onMouseWheel missing');
        }

        onPress(point) {
            if (this.record.containsPoint(point)) {
                this.record.toggle();
            }
            if (this.play.containsPoint(point)) {
                this.play.toggle();
            }
            if (this.stop.containsPoint(point)) {
                this.stop.toggle();
                if (this.onReset) {
                    this.onReset();
                }
            }
        }

        mapPositionToPoint(point) {
            let local = new PIXI.Point();
            this.renderer.plugins.interaction.mapPositionToPoint(
                local,
                point.x,
                point.y
            );
            return local
        }

        extractLocal(event) {
            return this.mapPositionToPoint(Events.extractPoint(event))
        }

        capture(event) {
            if (typeof event.mouseDownSubstitute != 'undefined') return false
            return true
        }

        startReplay() {
            if (this.onReset) {
                this.onReset();
            }
            this.record.startReplay();
        }

        showTouches() {
            this.addChild(this.touchGraphics);
        }

        recordEvent(event) {
            if (this.record.selected) {
                this.record.recordEvent(event);
            }
        }

        onStart(event, interaction) {
            let local = this.extractLocal(event);
            if (!this.toolbar.containsPoint(local)) {
                this.recordEvent(event);
                this.updateTouchGraphics(interaction);
            }
        }

        onMove(event, interaction) {
            let local = this.extractLocal(event);
            if (!this.toolbar.containsPoint(local)) {
                this.recordEvent(event);
                this.updateTouchGraphics(interaction);
            }
        }

        onEnd(event, interaction) {
            let local = this.extractLocal(event);
            if (this.toolbar.containsPoint(local)) {
                this.onPress(local);
            } else {
                this.recordEvent(event);
                this.updateTouchGraphics(interaction);
            }
        }

        updateTouchGraphics(interaction) {
            let current = interaction.current;
            let graphics = this.touchGraphics;
            if (graphics != null) {
                graphics.clear();
                for (let key of current.keys()) {
                    if (interaction.ended.has(key)) {
                        continue
                    }
                    let p = current.get(key);
                    if (key == 'mouse') {
                        graphics.beginFill(0xcc0000, 0.5);
                    } else {
                        graphics.beginFill(0xcccccc, 0.5);
                    }
                    graphics.drawCircle(p.x, p.y, 20);
                }
                graphics.endFill();
            }
        }
    }

    class AppTest extends PIXIApp {
        constructor(canvas, container) {
            super({ view: canvas, backgroundColor: 0x000000 });
            this.container = container;
        }

        sceneFactory() {
            return new RecorderTools(this.renderer)
        }

        setup() {
            super.setup();
            this.scene.setup(this.container);
        }

        run(reset = null) {
            this.scene.onReset = reset;
            console.log('Running AppTest');
            return this
        }
    }

    /**
     * Defines usefull default text styles.
     */
    class FontInfo {
        static get small() {
            return app.theme.textStyleSmall
        }

        static get normal() {
            return app.theme.textStyle
        }

        static get centered() {
            return Object.assign({}, app.theme.textStyle, { align: 'center' })
        }
    }

    /**
     * Static methods to support hyphenation of lines.
     *
     * @class Hypenate
     */
    class Hypenate {
        static splitPart(part) {
            let parts = part.split('-');
            if (parts.length == 1) return [part]
            let result = [];
            let last = parts.pop();
            for (let p of parts) {
                result.push(p + '-');
            }
            result.push(last);
            return result.filter(p => p.length > 0)
        }

        static splitWord(word) {
            if (typeof language == 'undefined') {
                if (word.indexOf('-') > -1) {
                    return word.split('-')
                }
                return [word]
            }
            let parts = language.hyphenate(word);
            let result = [];
            for (let part of parts) {
                for (let splitted of this.splitPart(part)) {
                    result.push(splitted);
                }
            }
            return result
        }

        static abbreviateLine(label, style, width) {
            const pixiStyle = new PIXI.TextStyle(style);
            let metrics = PIXI.TextMetrics.measureText(label, pixiStyle);
            while (metrics.width > width && label.length > 3) {
                label = label.slice(0, label.length - 1);
                metrics = PIXI.TextMetrics.measureText(label, pixiStyle);
            }
            label = label.slice(0, label.length - 1);
            return label + '…'
        }

        static splitLine(line, pixiStyle, width, space, minus) {
            let x = 0;
            let result = '';
            let words = line.split(' ');
            for (let word of words) {
                let wordMetrics = PIXI.TextMetrics.measureText(word, pixiStyle);
                if (x + wordMetrics.width >= width) {
                    let parts = this.splitWord(word);
                    let newWord = '';
                    if (parts.length == 1) {
                        newWord += '\n' + word + ' ';
                        x = wordMetrics.width + space.width;
                    } else {
                        let first = true;
                        let lastPart = '';
                        for (let part of parts) {
                            let partMetrics = PIXI.TextMetrics.measureText(
                                part,
                                pixiStyle
                            );
                            if (x + partMetrics.width + space.width > width) {
                                newWord +=
                                    (first || lastPart.endsWith('-')
                                        ? '\n'
                                        : '-\n') + part;
                                x = partMetrics.width;
                            } else {
                                newWord += part;
                                x += partMetrics.width;
                            }
                            lastPart = part;
                            first = false;
                        }
                        x += space.width;
                    }
                    result += newWord + ' ';
                } else {
                    result += word + ' ';
                    x += wordMetrics.width + space.width;
                }
            }
            return result
        }

        /**
         *  Main method and entry point for text hyphenation
         *
         * @static
         * @param {*} text
         * @param {*} style
         * @param {*} width
         * @memberof Hypenate
         * @returns {string}
         */
        static splitLines(text, style, width) {
            const pixiStyle = new PIXI.TextStyle(style);
            const lines = text.split('\n');
            const space = PIXI.TextMetrics.measureText(' ', pixiStyle);
            const minus = PIXI.TextMetrics.measureText('-', pixiStyle);
            let result = [];
            for (let line of lines) {
                result.push(this.splitLine(line, pixiStyle, width, space, minus));
            }
            return result.join('\n')
        }
    }

    class TextLabel extends PIXI.Text {
        /**
         *Creates an instance of TextLabel.
         * @param {string} text - The string that you would like the text to display
         * @param {object|PIXI.TextStyle} [style] - The style parameters
         * @param {canvas}
         * @memberof TextLabel
         */
        constructor(
            text,
            style = null,
            canvas = null,
            { minZoom = 0.1, maxZoom = 10 } = {}
        ) {
            super(text, style, canvas);
            this.normFontSize = this.style.fontSize;
            this.minZoom = minZoom;
            this.maxZoom = maxZoom;
        }

        zoom(factor) {
            let oldValue = parseFloat(this.style.fontSize) / this.normFontSize;
            let value = oldValue * factor;
            this.setZoom(value);
        }

        setZoom(value) {
            let oldValue = parseFloat(this.style.fontSize) / this.normFontSize;
            if (value > this.maxZoom) {
                value = this.maxZoom;
            }
            if (value < this.minZoom) {
                value = this.minZoom;
            }
            if (value != oldValue) {
                this.style.fontSize = Math.max(value * this.normFontSize, 1);
            }
        }

        setZoomAndScale(scale) {
            this.scale.set(1 / scale);
            this.setZoom(scale);
        }
    }

    /**
     * A specialization of the PIXI.Graphics class that allows to
     * resuse and place labels across different layout variants
     *
     * @export
     * @class LabeledGraphics
     * @extends {PIXI.Graphics}
     */
    class LabeledGraphics extends PIXI.Graphics {
        /**
         * Creates an instance of LabeledGraphics and defines a local label cache.
         *
         * @memberof LabeledGraphics
         */
        constructor() {
            super();
            this.labels = new Map();
        }

        _createText(label, fontInfo) {
            return new TextLabel(label, fontInfo)
        }

        /**
         * Main additional method. Ensures that a text object is created that is cached
         * under the given key.
         *
         * @param {*} key - The cache key
         * @param {*} label - The label to show
         * @param {*} [attrs={}] - Defines attributes of the text object.
         *                               align: 'right', 'left', or 'center'
         *                               justify: 'top', 'bottom', or 'center'
         *                               maxLines: {integer} truncates the text and adds ellipsis
         *                               maxHeight: {number} truncates text that needs more space and adds ellipsis
         *                               maxWidth: {number} word wraps text using hyphenation if possible
         * @param {*} [fontInfo=FontInfo.normal] - Defines PIXI.TextStyle attributes
         * @returns {PIXI.Text} - instance
         * @memberof LabeledGraphics
         */
        ensureLabel(key, label, attrs = {}, fontInfo = FontInfo.normal) {
            if (attrs.maxWidth && attrs.maxLines == 1) {
                label = Hypenate.abbreviateLine(label, fontInfo, attrs.maxWidth);
            } else {
                if (attrs.maxWidth) {
                    label = Hypenate.splitLines(label, fontInfo, attrs.maxWidth);
                }
                if (attrs.maxLines) {
                    label = this.truncateLabel(label, fontInfo, attrs.maxLines);
                }
                if (attrs.maxHeight) {
                    let styleInfo = new PIXI.TextStyle(fontInfo);
                    let metrics = PIXI.TextMetrics.measureText(label, styleInfo);
                    let maxLines = Math.max(attrs.maxHeight / metrics.lineHeight, 1);
                    label = this.truncateLabel(label, fontInfo, maxLines);
                }
            }

            if (!this.labels.has(key)) {
                let text = this._createText(label, fontInfo);
                this.labels.set(key, text);
                this.addChild(text);
            }
            let text = this.labels.get(key);
            for (let k in attrs) {
                text[k] = attrs[k];
            }
            if (label != text.text) text.text = label;
            // We do not follow the flexbox jargon and use align for x and justify for y axis
            // This deviation is needed to ensure backward compatability
            switch (attrs.justify || null) {
                case 'top':
                    text.anchor.y = 0;
                    break
                case 'bottom':
                    text.anchor.x = 1;
                    break
                default:
                    text.anchor.y = 0.5;
                    break
            }
            switch (attrs.align) {
                case 'right':
                    text.anchor.x = 1;
                    break
                case 'center':
                    text.anchor.x = 0.5;
                    break
                default:
                    text.anchor.x = 0;
                    break
            }
            text.visible = true;
            return text
        }

        /**
         * Private method that truncates the text and adds an ellipsis if there are more lines
         * than wanted
         *
         * @param {*} text
         * @param {*} style
         * @param {*} [maxLines=Infinity]
         * @returns {string}
         * @memberof LabeledGraphics
         */
        truncateLabel(text, style, maxLines = Infinity) {
            if (maxLines === Infinity) {
                return text
            }
            const { wordWrapWidth } = style;
            const pixiStyle = new PIXI.TextStyle(style);
            const { lines } = PIXI.TextMetrics.measureText(text, pixiStyle);
            let newText = text;
            if (lines.length > maxLines) {
                const truncatedLines = lines.slice(0, maxLines);
                const lastLine = truncatedLines[truncatedLines.length - 1];
                const words = lastLine.split(' ');
                const wordMetrics = PIXI.TextMetrics.measureText(
                    `\u00A0\n...\n${words.join('\n')}`,
                    pixiStyle
                );
                const [
                    spaceLength,
                    dotsLength,
                    ...wordLengths
                ] = wordMetrics.lineWidths;
                const { text: newLastLine } = wordLengths.reduce(
                    (data, wordLength, i) => {
                        if (
                            data.length + wordLength + spaceLength >=
                            wordWrapWidth
                        ) {
                            return { ...data, length: wordWrapWidth }
                        }
                        return {
                            text: `${data.text}${i > 0 ? ' ' : ''}${words[i]}`,
                            length: data.length + wordLength + spaceLength
                        }
                    },
                    { text: '', length: dotsLength }
                );
                truncatedLines[truncatedLines.length - 1] = `${newLastLine}...`;
                newText = truncatedLines.join('\n');
            }
            return newText
        }

        /**
         * Returns the label for the given key.
         *
         * @param {*} key
         * @returns {Object}
         * @memberof LabeledGraphics
         */
        getLabel(key) {
            return this.labels.get(key)
        }

        /**
         * Hides the label with the given key.
         * @param {*} key
         * @memberof LabeledGraphics
         */
        hideLabel(key) {
            let label = this.labels.get(key);
            if (label) {
                label.visible = false;
            }
        }

        /**
         * Removes the label with the given key.
         * @param {*} key
         * @memberof LabeledGraphics
         */
        removeLabel(key) {
            let label = this.labels.get(key);
            this.labels.delete(key);
            label.destroy();
        }

        /**
         * Ensures that labels are hidden on clear.
         *
         * @memberof LabeledGraphics
         */
        clear() {
            super.clear();
            for (let key of this.labels.keys()) {
                this.hideLabel(key);
            }
        }

        /**
         * Logs debugging infos
         *
         * @memberof LabeledGraphics
         */
        debugInfos() {
            console.log({ size: this.labels.size, labels: this.labels });
        }
    }

    const labelCache = new Map();

    function getTexture(label, fontInfo = FontInfo.normal) {
        let key = label + fontInfo.fontFamily + fontInfo.fontSize;

        if (labelCache.has(key)) {
            return labelCache.get(key)
        }
        let expandedFont = Object.assign({}, fontInfo);
        expandedFont.fontSize *= window.devicePixelRatio;
        let text = new PIXI.Text(label, expandedFont);
        text.updateText();
        labelCache.set(key, text.texture);
        return text.texture
    }

    class SpriteLabel extends PIXI.Sprite {
        constructor(label, fontInfo) {
            let texture = getTexture(label, fontInfo);
            super(texture);
            this.label = label;
            this.fontInfo = fontInfo;
            this.scale.set(0.8 / window.devicePixelRatio);
        }

        set text(label) {
            this.label = label;
            this.texture = getTexture(label, this.fontInfo);
        }

        get text() {
            return this.label
        }
    }

    class BitmapLabeledGraphics extends LabeledGraphics {
        _createText(label, fontInfo) {
            let texture = getTexture(label, fontInfo);
            return new SpriteLabel(texture)
        }
    }

    class Ticks {
        get reservedPrefixes() {
            return ['decade', 'year', 'month', 'day', 'hour', 'minute', 'second']
        }

        static get largeTickSize() {
            return 4.2
        }

        get minWidth() {
            return 10
        }

        format(available) {
            return { year: '2-digit', timeZone: 'UTC' }
        }

        get minLabelWidth() {
            return 44
        }

        get formatKey() {
            return this.key
        }

        dateKey(date) {
            return this.key + date.getFullYear()
        }

        *iter(start, end) {
            let date = this.iterStart(start);
            while (date <= end) {
                yield date;
                date = this.next(date);
            }
            yield date;
        }

        *iterRanges(range) {
            for (let date of this.iter(range.start, range.end)) {
                let next = this.next(date);
                yield { start: date, end: next };
            }
        }

        selectedRange(timeline, info) {
            let first = null;
            let last = null;
            let visibleFirst = null;
            let visibleLast = null;
            let units = 0;
            for (let { start, end } of this.iterRanges(info)) {
                if (timeline.visibleRange(start, end)) {
                    if (first == null) {
                        first = start;
                    }
                    last = end;
                }
                if (timeline.visibleDate(start) && timeline.visibleDate(end)) {
                    units += 1;
                    if (visibleFirst == null) {
                        visibleFirst = start;
                    }
                    visibleLast = end;
                }
            }
            if (first == null) return info
            return {
                start: first,
                end: last,
                visibleStart: visibleFirst,
                visibleEnd: visibleLast,
                units: units
            }
        }

        drawTick(timeline, x, y, date) {
            let visible = date > timeline.start && date < timeline.end;
            if (!visible) return false
            timeline.drawTick(x);
            return true
        }

        toLocaleString(date, format) {
            return date.toLocaleDateString('de', format)
        }

        draw(
            timeline,
            range,
            width,
            height,
            available,
            format,
            nextFormat,
            level,
            extraTicks = false
        ) {
            let first = null;
            let last = null;
            let keyedFormat = format ? format[this.formatKey] : null;
            let keyedNextFormat = nextFormat ? nextFormat[this.formatKey] : null;
            let redundant = nextFormat ? this.formatKey in nextFormat : false;
            let fullyRedundant =
                keyedFormat != null && keyedFormat == keyedNextFormat;
            let y = timeline.getY();
            for (let { start, end } of this.iterRanges(range)) {
                let x = timeline.toX(start);
                let xx = x;
                let yy = y + timeline.tickLabelOffset(-1);
                if (this.drawTick(timeline, x, y, start) && format) {
                    let key = this.dateKey(start);
                    let text = this.toLocaleString(start, format);
                    let align = 'center';
                    if (nextFormat) {
                        yy = y + timeline.tickLabelOffset(-1, 1);
                        align = 'left';
                        timeline.drawTick(x, Ticks.largeTickSize);
                        let nextX = timeline.toX(end) - 100;
                        if (x < 0 && nextX > -100 && !redundant) {
                            xx = Math.min(4, nextX);
                        } else {
                            xx -= 2;
                        }
                    } else if (level > 0) {
                        xx = x + available / 2;
                    }

                    if (!fullyRedundant) {
                        timeline.ensureLabel(
                            key,
                            text,
                            { x: xx, y: yy, align },
                            FontInfo.small
                        );
                    }
                    if (extraTicks) timeline.drawTick(x, -level);
                }
                if (timeline.visibleRange(start, end)) {
                    if (first == null) first = start;
                    last = end;
                }
            }
            if (first == null) return null
            return { start: first, end: last }
        }

        drawExtra(timeline, range, size) {
            for (let { start } of this.iterRanges(range)) {
                if (timeline.visibleDate(start)) {
                    let x = timeline.toX(start);
                    timeline.drawTick(x, -size);
                }
            }
        }
    }

    class DecadeTicks extends Ticks {
        get milliseconds() {
            return 10 * 365 * 24 * 60 * 60 * 1000
        }

        format(available) {
            return { year: 'numeric', timeZone: 'UTC' }
        }

        selection(timeline, dates, selected) {
            return dates
        }

        get key() {
            return 'decade'
        }

        get formatKey() {
            return 'year'
        }

        iterStart(start) {
            let modulo = start.getFullYear() % 10;
            let year = start.getFullYear() - modulo;
            return Dates.create(year, 0, 1)
        }

        next(decade) {
            return Dates.nextYear(decade, 10)
        }
    }

    class YearTicks extends Ticks {
        get milliseconds() {
            return 365 * 24 * 60 * 60 * 1000
        }

        format(available) {
            if (available < 44) return { year: '2-digit', timeZone: 'UTC' }
            return { year: 'numeric', timeZone: 'UTC' }
        }

        get minLabelWidth() {
            return 22
        }

        get key() {
            return 'year'
        }

        iterStart(start) {
            return Dates.create(start.getFullYear(), 0, 1)
        }

        next(year) {
            return Dates.nextYear(year)
        }
    }

    class MonthTicks extends Ticks {
        get milliseconds() {
            return (365 / 12) * 24 * 60 * 60 * 1000
        }

        format(available) {
            let format = { month: 'narrow', timeZone: 'UTC' };
            if (available > 44) format.month = 'short';
            if (available > 66) format.year = '2-digit';
            if (available > 100) {
                format.month = 'long';
                format.year = 'numeric';
            }
            return format
        }

        get minLabelWidth() {
            return 32
        }

        get key() {
            return 'month'
        }

        dateKey(date) {
            return this.key + date.getFullYear() + date.getMonth()
        }

        iterStart(start) {
            return Dates.create(start.getFullYear(), start.getMonth(), 1)
        }

        next(month) {
            return Dates.nextMonth(month)
        }
    }

    class DayTicks extends Ticks {
        get milliseconds() {
            return 24 * 60 * 60 * 1000
        }

        format(available) {
            let format = { day: 'numeric', timeZone: 'UTC' };
            if (available > 44) format.month = 'short';
            if (available > 100) {
                format.month = 'long';
                format.year = '2-digit';
            }
            if (available > 150) {
                format.weekday = 'short';
            }
            if (available > 200) {
                format.year = 'numeric';
                format.weekday = 'long';
            }
            return format
        }

        get key() {
            return 'day'
        }

        dateKey(date) {
            return this.key + date.getFullYear() + date.getMonth() + date.getDate()
        }

        iterStart(start) {
            return Dates.create(
                start.getFullYear(),
                start.getMonth(),
                start.getDate()
            )
        }

        next(date) {
            return Dates.nextDay(date)
        }
    }

    class TimeTicks {
        constructor(...ticks) {
            this.ticks = ticks;
        }

        selectedRange(timeline) {
            let info = { start: timeline.start, end: timeline.end, units: 0 };
            for (let ticks of this.ticks) {
                info = ticks.selectedRange(timeline, info);
                if (info.units > 1) {
                    timeline.selection = [info.visibleStart, info.visibleEnd];
                    return
                }
            }
            timeline.selection = [info.start, info.end];
        }

        draw(timeline, width, height) {
            let range = timeline;
            let start = timeline.toX(range.start);
            let end = timeline.toX(range.end);
            let size = end - start;
            let duration = timeline.end - timeline.start;
            let formats = new Map();
            let nextFormats = new Map();
            let availables = new Map();
            let previous = null;
            let visible = [];
            for (let ticks of this.ticks) {
                let amount = ticks.milliseconds / duration;
                let available = amount * size;
                availables.set(ticks, available);
                if (available < ticks.minWidth) break
                formats.set(
                    ticks,
                    available < ticks.minLabelWidth ? null : ticks.format(available)
                );
                nextFormats.set(previous, formats.get(ticks));
                previous = ticks;
                visible.push(ticks);
            }
            let level = 0;
            let ranges = [];
            for (let ticks of visible) {
                if (range == null) continue
                range = ticks.draw(
                    timeline,
                    range,
                    width,
                    height,
                    availables.get(ticks),
                    formats.get(ticks),
                    nextFormats.get(ticks),
                    level
                );
                if (range) {
                    ranges.push({ ticks, range });
                }
                level += 1;
            }

            let extraLevel = ranges.length - 1;
            let extraStart = extraLevel;
            for (let { ticks, range } of ranges) {
                ticks.drawExtra(timeline, range, extraLevel);
                extraLevel -= 1;
                if (extraLevel <= 0) {
                    continue
                }
            }

            timeline.drawTick(start, Ticks.largeTickSize);
            timeline.drawTick(start, -extraStart);
            timeline.drawTick(end, Ticks.largeTickSize);
            timeline.drawTick(end, -extraStart);
        }
    }

    class ColorRanges {
        constructor(label, color, ranges) {
            this.label = label;
            this.color = color;
            this.ranges = ranges;
        }

        draw(timeline, width, height, size = 12) {
            let minimum = 1 / Capabilities.devicePixelRatio;
            let h2 = size;
            timeline.lineStyle(size, this.color);
            for (let range of this.ranges) {
                if (range.to === null) {
                    range.to = Dates.nextDay(range.from);
                }
                let start = timeline.toX(range.from);
                let end = timeline.toX(range.to);
                if (end < start + minimum) {
                    end = start + minimum;
                }
                timeline.moveTo(start, h2);
                timeline.lineTo(end, h2);
            }
        }
    }

    class Timeline extends BitmapLabeledGraphics {
        constructor(
            width,
            height,
            {
                ticks = null,
                baseLine = 0.5,
                showRange = true,
                throwDamping = 0.95
            } = {}
        ) {
            super();
            this.wantedWidth = width;
            this.wantedHeight = height;
            this.extraLeft = 0;
            this.extraRight = 0;
            this.inset = 5;
            this.showRange = showRange;
            this.baseLine = baseLine;
            this.tickHeight = 4;
            this.zoom = 1;
            this.minZoom = 1;
            this.maxZoom = 12000;
            this.scroll = 0;
            this.deltas = [];
            this.labelDates = [];
            this.colorRanges = [];
            this.rangeColors = new Cycle(
                Colors.eminence,
                Colors.steelblue,
                Colors.ochre,
                Colors.turquoise
            );
            this.callbacks = [];
            this.onTapCallbacks = [];
            this.onDoubleTapCallbacks = [];
            this.onLongPressCallbacks = [];
            this.progress = null;
            this.start = null;
            this.end = null;
            this.selection = null;
            this.autoScroll = false;
            this.direction = -1;
            this.throwDamping = throwDamping;
            this.timeticks =
                ticks ||
                new TimeTicks(
                    new DecadeTicks(),
                    new YearTicks(),
                    new MonthTicks(),
                    new DayTicks()
                );
            this.labelPrefix = '__';
        }

        updateSelection() {
            if (this.visibleDate(this.start) && this.visibleDate(this.end)) {
                this.selection = [this.start, this.end];
            } else {
                this.timeticks.selectedRange(this);
            }

            return this.selection
        }

        addCallback(callback) {
            this.callbacks.push(callback);
        }

        addTabCallback(callback) {
            this.onTapCallbacks.push(callback);
        }

        addDoubleTapCallback(callback) {
            this.onDoubleTapCallbacks.push(callback);
        }

        addLongPressCallback(callback) {
            this.onLongPressCallbacks.push(callback);
        }

        addLabels(labels) {
            this.labelDates = labels;
        }

        range(start, end) {
            this.start = start;
            this.end = end;
        }

        draw(width, height) {
            this.wantedWidth = width;
            this.wantedHeight = height;
            this.redraw();
        }

        updateColorRanges(w, h) {
            let xx = w - this.inset;
            let size = FontInfo.small.fontSize;
            let yy = h - size;
            for (let i = this.colorRanges.length - 1; i >= 0; i--) {
                let cr = this.colorRanges[i];
                let label = cr.label;
                cr.draw(this, w, h);
                let current = this.ensureLabel(
                    'colorRange:' + label,
                    label,
                    { x: xx, y: yy, align: 'right' },
                    FontInfo.small
                );
                let r = current.getBounds();
                xx -= r.width + 16;

                this.lineStyle(size, cr.color);
                this.moveTo(xx, yy);
                this.lineTo(xx + size, yy);
                xx -= size + 4;
            }
        }

        drawSelectedRamge(selected) {
            this.lineStyle(2, app.theme.primaryColor);
            let start = this.toX(selected[0]);
            let end = this.toX(selected[1]);
            this.moveTo(start, 0);
            this.lineTo(end, 0);
            this.drawTick(start, -1.5, 0);
            this.drawTick(end, -1.5, 0);
        }

        redraw() {
            this.clear();
            let h = this.wantedHeight;
            let w = this.wantedWidth;
            let y = this.getY();
            this.prepareLabels();
            this.updateColorRanges(w, h);

            this.lineStyle(2, 0xffffff);
            if (this.start != null && this.end != null) {
                this.moveTo(this.toX(this.start), y);
                this.lineTo(this.toX(this.end), y);
                this.updateTicksAndLabels(w, h);
                this.updateSelection();
                let selected = this.selection;
                if (selected[0] != this.start && selected[1] != this.end) {
                    if (this.showRange) this.drawSelectedRamge(selected);
                }
                for (let callback of this.callbacks) {
                    callback(this.scroll, this.zoom, this.selection);
                }
            } else {
                this.moveTo(this.inset, y);
                this.lineTo(w - this.inset, y);
            }

            if (this.progress != null && this.progress < 1) {
                this.lineStyle(2, 0xccccff);
                this.moveTo(this.inset, y);
                this.lineTo((w - this.inset) * this.progress, y);
            }
        }

        totalWidth(bounded = false) {
            let w = this.wantedWidth - 2 * this.inset;
            return w * this.validZoom(this.zoom, bounded)
        }

        validZoom(zoom, bounded = true) {
            let overshoot = bounded ? 1.0 : 2.0;
            zoom = Math.max(zoom, this.minZoom / overshoot);
            zoom = Math.min(zoom, this.maxZoom * overshoot);
            return zoom
        }

        getY() {
            return this.wantedHeight * this.baseLine
        }

        toX(date) {
            let total = this.end - this.start;
            let offset = this.inset + this.scroll;
            let width = this.totalWidth();
            let delta = date - this.start;
            let ratio = delta / total;
            return offset + ratio * width
        }

        fromX(value) {
            let total = this.end - this.start;
            let offset = this.inset + this.scroll;
            let width = this.totalWidth();
            let ratio = (value - offset) / width;
            let time = this.start.getTime() + total * ratio;
            let date = new Date(time);
            return date
        }

        drawTick(x, direction = 1, y = null) {
            if (y == null) {
                y = this.getY();
            }
            this.moveTo(x, y);
            this.lineTo(x, y - this.tickHeight * direction * this.direction);
        }

        prepareLabels() {
            for (let key of this.labels.keys()) {
                if (!key.startsWith(this.labelPrefix))
                    this.labels.get(key).visible = false;
            }
        }

        updateTicksAndLabels(width, height) {
            this.drawTick(this.toX(this.start));
            this.drawTick(this.toX(this.end));
            this.timeticks.draw(this, width, height);
            this.updateLabels(width, height);
        }

        visibleDate(date, offset = 0) {
            if (date >= this.start && date <= this.end) {
                let x = this.toX(date) + offset;
                return x > 0 && x < this.wantedWidth
            }
            return false
        }

        visibleRange(start, end) {
            let x = this.toX(start);
            if (x > this.wantedWidth) return false
            x = this.toX(end);
            if (x < 0) return false
            return true
        }

        tickLabelOffset(direction = 1, level = 0) {
            let fs = FontInfo.small.fontSize;
            let dh = fs + level * (fs + 2);
            return this.direction * direction * dh
        }

        updateLabels(width, height) {
            let h2 = height / 2;
            if (this.labelDates) {
                let last = null;
                let y = h2 + this.tickLabelOffset();
                for (let i = this.labelDates.length - 1; i >= 0; i--) {
                    let [label, date] = this.labelDates[i];
                    let align = 'center'; // (last == null) ? 'right' : 'left'
                    let x = this.toX(date);
                    let current = this.ensureLabel(
                        this.labelPrefix + label,
                        label,
                        {
                            x: x,
                            y: y,
                            align
                        },
                        FontInfo.small
                    );
                    let r = current.getBounds();
                    current.visible = !(last != null && r.x + r.width > last.x);
                    if (current.visible) {
                        this.drawTick(x, -1);
                        last = r;
                    }
                }
            } else {
                let start = this.start.toLocaleDateString('de', {
                    year: 'numeric',
                    month: 'numeric',
                    day: 'numeric'
                });
                let end = this.end.toLocaleDateString('de', {
                    year: 'numeric',
                    month: 'numeric',
                    day: 'numeric'
                });
                this.ensureLabel(this.labelPrefix + 'start', start, {
                    x: this.toX(this.start),
                    y: h2
                });
                this.ensureLabel(this.labelPrefix + 'end', end, {
                    x: this.toX(this.end),
                    y: h2,
                    align: 'right'
                });
            }
        }

        onZoom(zoom, anchor) {
            let date = this.fromX(anchor.x);
            let newZoom = this.validZoom(this.zoom * zoom, false);
            this.zoom = newZoom;
            let newX = this.toX(date);
            this.scroll += anchor.x - newX;
        }

        onStart(event, interaction) {
            this.killTweens();
            this.deltas = [];
            this.validScroll();
            if (typeof ThrowPropsPlugin != 'undefined') {
                ThrowPropsPlugin.track(this, 'delta');
            }
        }

        onMove(event, interaction) {
            let delta = interaction.delta();
            if (delta == null) {
                return
            }
            this.scroll += delta.x;
            while (this.deltas.length > 10) {
                this.deltas.pop(0);
            }
            this.deltas.push(delta.x);
            if (interaction.current.size > 1) {
                this.onZoom(delta.zoom, delta.about);
            }
            this.redraw();
        }

        onEnd(event, interaction) {
            if (typeof ThrowPropsPlugin != 'undefined') {
                let vel = ThrowPropsPlugin.getVelocity(this, 'delta');
                ThrowPropsPlugin.untrack(this);
            }

            this.killTweens();
            this.redraw();
            let delta = 0;
            for (let d of this.deltas) {
                delta += d;
            }
            if (this.deltas.length > 0) {
                delta /= this.deltas.length;
            }
            this.autoScroll = true;
            let anchor = interaction.current.mean();
            this.keepInBounds(delta, anchor);

            for (let key of interaction.ended.keys()) {
                if (interaction.isDoubleTap(key)) {
                    this.onDoubleTap(event, interaction, key);
                } else if (interaction.isTap(key)) {
                    this.onTap(event, interaction, key);
                } else if (interaction.isLongPress(key)) {
                    this.onLongPress(event, interaction, key);
                }
            }
        }

        onLongPress(event, interaction, key) {
            for (let callback of this.onLongPressCallbacks) {
                callback(event, interaction, key);
            }
        }

        onTap(event, interaction, key) {
            for (let callback of this.onTapCallbacks) {
                callback(event, interaction, key);
            }
        }

        onDoubleTap(event, interaction, key) {
            for (let callback of this.onDoubleTapCallbacks) {
                callback(event, interaction, key);
            }
        }

        _scrollMinimum(bounded) {
            let total = this.totalWidth(bounded);
            return -total + this.wantedWidth - 2 * this.inset
        }

        _scrollMaximum(bounded) {
            let total = this.totalWidth(bounded);
            let limit = this.wantedWidth;
            if (total > limit) return 0
            let w = limit - 2 * this.inset;
            return (w - total) / 2
        }

        scrollMinimum(bounded) {
            return this._scrollMinimum(bounded) - this.extraRight
        }

        scrollMaximum(bounded) {
            return this._scrollMaximum(bounded) + this.extraLeft
        }

        killTweens() {
            TweenLite.killTweensOf(this);
            this.autoScroll = false;
        }

        validScroll(bounded = true) {
            let minimum = this.scrollMinimum(bounded);
            let maximum = this.scrollMaximum(bounded);
            if (this.scroll < minimum) {
                this.scroll = minimum;
            }
            if (this.scroll > maximum) {
                this.scroll = maximum;
            }
        }

        keepInBounds(delta, anchor) {
            let bounded = true;
            let minimum = this.scrollMinimum(bounded);
            let maximum = this.scrollMaximum(bounded);
            let tweens = {};
            if (this.zoom > this.maxZoom) {
                tweens.zoom = this.maxZoom;
                let date = this.fromX(anchor.x);
                let oldZoom = this.zoom;
                this.zoom = this.maxZoom;
                let newX = this.toX(date);
                tweens.scroll = this.scroll + anchor.x - newX;
                this.zoom = oldZoom;
            } else {
                if (this.zoom < this.minZoom) {
                    tweens.zoom = this.minZoom;
                }
                if (this.scroll > maximum) {
                    tweens.scroll = maximum;
                }
                if (this.scroll < minimum) {
                    tweens.scroll = minimum;
                }
            }
            if (!isEmpty(tweens)) {
                tweens.onUpdate = () => {
                    this.redraw();
                };
                TweenLite.to(this, 0.5, tweens).delay(0.1);
                return
            }
            this.scroll += delta;
            delta *= this.throwDamping;
            this.redraw();
            if (Math.abs(delta) > 1 && this.autoScroll) {
                setTimeout(() => this.keepInBounds(delta, anchor), 1000 / 100);
            }
        }

        onMouseWheel(event) {
            this.killTweens();
            let direction = event.detail < 0 || event.wheelDelta > 0;
            let anchor = { x: event.clientX, y: event.clientY };
            const zoomFactor = 1.5;
            this.onZoom(direction ? zoomFactor : 1 / zoomFactor, anchor);
            this.redraw();
            this.keepInBounds(0, anchor);
        }

        showRanges(ranges, label = 'Untitled', color = null) {
            for (let cr of this.colorRanges) {
                if (cr.label == label) return
            }
            while (this.colorRanges.length >= this.rangeColors.length) {
                this.colorRanges.shift();
            }
            this.colorRanges.push(new ColorRanges(label, color, ranges));
            this.redraw();
        }
    }

    /**
     * pixi.js scrollbox: a masked content box that can scroll vertically or horizontally with scrollbars
     */
    class Scrollbox extends PIXI.Container {
        /**
         * create a scrollbox
         * @param {object} options
         * @param {boolean} [options.dragScroll=true] user may drag the content area to scroll content
         * @param {string} [options.overflowX=auto] (none, scroll, hidden, auto) this changes whether the scrollbar is shown
         * @param {string} [options.overflowY=auto] (none, scroll, hidden, auto) this changes whether the scrollbar is shown
         * @param {string} [options.overflow] (none, scroll, hidden, auto) sets overflowX and overflowY to this value
         * @param {number} [options.boxWidth=100] width of scrollbox including scrollbar (in pixels)
         * @param {number} [options.boxHeight=100] height of scrollbox including scrollbar (in pixels)
         * @param {number} [options.scrollbarSize=10] size of scrollbar (in pixels)
         * @param {number} [options.scrollbarOffsetHorizontal=0] offset of horizontal scrollbar (in pixels)
         * @param {number} [options.scrollbarOffsetVertical=0] offset of vertical scrollbar (in pixels)
         * @param {boolean} [options.stopPropagation=true] call stopPropagation on any events that impact scrollbox
         * @param {number} [options.scrollbarBackground=0xdddddd] background color of scrollbar
         * @param {number} [options.scrollbarBackgroundAlpha=1] alpha of background of scrollbar
         * @param {number} [options.scrollbarForeground=0x888888] foreground color of scrollbar
         * @param {number} [options.scrollbarForegroundAlpha=1] alpha of foreground of scrollbar
         * @param {string} [options.underflow=top-left] what to do when content underflows the scrollbox size: none: do nothing; (left/right/center AND top/bottom/center); OR center (e.g., 'top-left', 'center', 'none', 'bottomright')
         * @param {(boolean|number)} [options.fade] fade the scrollbar when not in use (true = 1000ms)
         * @param {number} [options.fadeWait=3000] time to wait before fading the scrollbar if options.fade is set
         * @param {(string|function)} [options.fadeEase=easeInOutSine] easing function to use for fading
         */
        constructor(options) {
            super();
            this.options = Object.assign(
                {},
                {
                    boxWidth: 100,
                    boxHeight: 100,
                    scrollbarSize: 10,
                    scrollbarBackground: 14540253,
                    scrollbarBackgroundAlpha: 1,
                    scrollbarForeground: 8947848,
                    scrollbarForegroundAlpha: 1,
                    dragScroll: true,
                    stopPropagation: true,
                    scrollbarOffsetHorizontal: 0,
                    scrollbarOffsetVertical: 0,
                    underflow: 'top-left',
                    fadeScrollbar: false,
                    fadeWait: 3000,
                    fadeEase: 'easeInOutSine'
                },
                options
            );
            this.ease = new PIXI.extras.Ease.list();

            this.on('added', event => {
                this.update();
            });

            /**
             * content in placed in here
             * you can use any function from pixi-viewport on content to manually move the content (see https://davidfig.github.io/pixi-viewport/jsdoc/)
             * @type {PIXI.extras.Viewport}
             */
            this.content = this.addChild(
                new PIXI.extras.Viewport({
                    passiveWheel: this.options.stopPropagation,
                    stopPropagation: this.options.stopPropagation,
                    screenWidth: this.options.boxWidth,
                    screenHeight: this.options.boxHeight
                })
            );
            this.content.decelerate().on('moved', () => this._drawScrollbars());

            /**
             * graphics element for drawing the scrollbars
             * @type {PIXI.Graphics}
             */
            this.scrollbar = this.addChild(new PIXI.Graphics());
            this.scrollbar.interactive = true;
            this.scrollbar.on('pointerdown', this.scrollbarDown, this);
            this.interactive = true;
            this.on('pointermove', this.scrollbarMove, this);
            this.on('pointerup', this.scrollbarUp, this);
            this.on('pointercancel', this.scrollbarUp, this);
            this.on('pointerupoutside', this.scrollbarUp, this);
            this._maskContent = this.addChild(new PIXI.Graphics());
            this.update();
        }

        /**
         * offset of horizontal scrollbar (in pixels)
         * @type {number}
         */
        get scrollbarOffsetHorizontal() {
            return this.options.scrollbarOffsetHorizontal
        }
        set scrollbarOffsetHorizontal(value) {
            this.options.scrollbarOffsetHorizontal = value;
        }

        /**
         * offset of vertical scrollbar (in pixels)
         * @type {number}
         */
        get scrollbarOffsetVertical() {
            return this.options.scrollbarOffsetVertical
        }
        set scrollbarOffsetVertical(value) {
            this.options.scrollbarOffsetVertical = value;
        }

        /**
         * disable the scrollbox (if set to true this will also remove the mask)
         * @type {boolean}
         */
        get disable() {
            return this._disabled
        }
        set disable(value) {
            if (this._disabled !== value) {
                this._disabled = value;
                this.update();
            }
        }

        /**
         * call stopPropagation on any events that impact scrollbox
         * @type {boolean}
         */
        get stopPropagation() {
            return this.options.stopPropagation
        }
        set stopPropagation(value) {
            this.options.stopPropagation = value;
        }

        /**
         * user may drag the content area to scroll content
         * @type {boolean}
         */
        get dragScroll() {
            return this.options.dragScroll
        }
        set dragScroll(value) {
            this.options.dragScroll = value;
            if (value) {
                this.content.drag();
            } else {
                this.content.removePlugin('drag');
            }
            this.update();
        }

        /**
         * width of scrollbox including the scrollbar (if visible)- this changes the size and not the scale of the box
         * @type {number}
         */
        get boxWidth() {
            return this.options.boxWidth
        }
        set boxWidth(value) {
            this.options.boxWidth = value;
            this.content.screenWidth = value;
            this.update();
        }

        /**
         * sets overflowX and overflowY to (scroll, hidden, auto) changing whether the scrollbar is shown
         * scroll = always show scrollbar
         * hidden = hide overflow and do not show scrollbar
         * auto = if content is larger than box size, then show scrollbar
         * @type {string}
         */
        get overflow() {
            return this.options.overflow
        }
        set overflow(value) {
            this.options.overflow = value;
            this.options.overflowX = value;
            this.options.overflowY = value;
            this.update();
        }

        /**
         * sets overflowX to (scroll, hidden, auto) changing whether the scrollbar is shown
         * scroll = always show scrollbar
         * hidden = hide overflow and do not show scrollbar
         * auto = if content is larger than box size, then show scrollbar
         * @type {string}
         */
        get overflowX() {
            return this.options.overflowX
        }
        set overflowX(value) {
            this.options.overflowX = value;
            this.update();
        }

        /**
         * sets overflowY to (scroll, hidden, auto) changing whether the scrollbar is shown
         * scroll = always show scrollbar
         * hidden = hide overflow and do not show scrollbar
         * auto = if content is larger than box size, then show scrollbar
         * @type {string}
         */
        get overflowY() {
            return this.options.overflowY
        }
        set overflowY(value) {
            this.options.overflowY = value;
            this.update();
        }

        /**
         * height of scrollbox including the scrollbar (if visible) - this changes the size and not the scale of the box
         * @type {number}
         */
        get boxHeight() {
            return this.options.boxHeight
        }
        set boxHeight(value) {
            this.options.boxHeight = value;
            this.content.screenHeight = value;
            this.update();
        }

        /**
         * scrollbar size in pixels
         * @type {number}
         */
        get scrollbarSize() {
            return this.options.scrollbarSize
        }
        set scrollbarSize(value) {
            this.options.scrollbarSize = value;
        }

        /**
         * width of scrollbox less the scrollbar (if visible)
         * @type {number}
         * @readonly
         */
        get contentWidth() {
            return (
                this.options.boxWidth -
                (this.isScrollbarVertical ? this.options.scrollbarSize : 0)
            )
        }

        /**
         * height of scrollbox less the scrollbar (if visible)
         * @type {number}
         * @readonly
         */
        get contentHeight() {
            return (
                this.options.boxHeight -
                (this.isScrollbarHorizontal ? this.options.scrollbarSize : 0)
            )
        }

        /**
         * is the vertical scrollbar visible
         * @type {boolean}
         * @readonly
         */
        get isScrollbarVertical() {
            return this._isScrollbarVertical
        }

        /**
         * is the horizontal scrollbar visible
         * @type {boolean}
         * @readonly
         */
        get isScrollbarHorizontal() {
            return this._isScrollbarHorizontal
        }

        /**
         * top coordinate of scrollbar
         */
        get scrollTop() {
            return this.content.top
        }

        /**
         * left coordinate of scrollbar
         */
        get scrollLeft() {
            return this.content.left
        }

        /**
         * width of content area
         * if not set then it uses content.width to calculate width
         */
        get scrollWidth() {
            return this._scrollWidth || this.content.width
        }
        set scrollWidth(value) {
            this._scrollWidth = value;
        }

        /**
         * height of content area
         * if not set then it uses content.height to calculate height
         */
        get scrollHeight() {
            return this._scrollHeight || this.content.height
        }
        set scrollHeight(value) {
            this._scrollHeight = value;
        }

        /**
         * draws scrollbars
         * @private
         */
        _drawScrollbars() {
            this._isScrollbarHorizontal =
                this.overflowX === 'scroll'
                    ? true
                    : ['hidden', 'none'].indexOf(this.overflowX) !== -1
                    ? false
                    : this.scrollWidth > this.options.boxWidth;
            this._isScrollbarVertical =
                this.overflowY === 'scroll'
                    ? true
                    : ['hidden', 'none'].indexOf(this.overflowY) !== -1
                    ? false
                    : this.scrollHeight > this.options.boxHeight;
            this.scrollbar.clear();
            let options = {};
            options.left = 0;
            options.right =
                this.scrollWidth +
                (this._isScrollbarVertical ? this.options.scrollbarSize : 0);
            options.top = 0;
            options.bottom =
                this.scrollHeight +
                (this.isScrollbarHorizontal ? this.options.scrollbarSize : 0);
            const width =
                this.scrollWidth +
                (this.isScrollbarVertical ? this.options.scrollbarSize : 0);
            const height =
                this.scrollHeight +
                (this.isScrollbarHorizontal ? this.options.scrollbarSize : 0);
            this.scrollbarTop = (this.content.top / height) * this.boxHeight;
            this.scrollbarTop = this.scrollbarTop < 0 ? 0 : this.scrollbarTop;
            this.scrollbarHeight = (this.boxHeight / height) * this.boxHeight;
            this.scrollbarHeight =
                this.scrollbarTop + this.scrollbarHeight > this.boxHeight
                    ? this.boxHeight - this.scrollbarTop
                    : this.scrollbarHeight;
            this.scrollbarLeft = (this.content.left / width) * this.boxWidth;
            this.scrollbarLeft = this.scrollbarLeft < 0 ? 0 : this.scrollbarLeft;
            this.scrollbarWidth = (this.boxWidth / width) * this.boxWidth;
            this.scrollbarWidth =
                this.scrollbarWidth + this.scrollbarLeft > this.boxWidth
                    ? this.boxWidth - this.scrollbarLeft
                    : this.scrollbarWidth;
            if (this.isScrollbarVertical) {
                this.scrollbar
                    .beginFill(
                        this.options.scrollbarBackground,
                        this.options.scrollbarBackgroundAlpha
                    )
                    .drawRect(
                        this.boxWidth -
                            this.scrollbarSize +
                            this.options.scrollbarOffsetVertical,
                        0,
                        this.scrollbarSize,
                        this.boxHeight
                    )
                    .endFill();
            }
            if (this.isScrollbarHorizontal) {
                this.scrollbar
                    .beginFill(
                        this.options.scrollbarBackground,
                        this.options.scrollbarBackgroundAlpha
                    )
                    .drawRect(
                        0,
                        this.boxHeight -
                            this.scrollbarSize +
                            this.options.scrollbarOffsetHorizontal,
                        this.boxWidth,
                        this.scrollbarSize
                    )
                    .endFill();
            }
            if (this.isScrollbarVertical) {
                this.scrollbar
                    .beginFill(
                        this.options.scrollbarForeground,
                        this.options.scrollbarForegroundAlpha
                    )
                    .drawRect(
                        this.boxWidth -
                            this.scrollbarSize +
                            this.options.scrollbarOffsetVertical,
                        this.scrollbarTop,
                        this.scrollbarSize,
                        this.scrollbarHeight
                    )
                    .endFill();
            }
            if (this.isScrollbarHorizontal) {
                this.scrollbar
                    .beginFill(
                        this.options.scrollbarForeground,
                        this.options.scrollbarForegroundAlpha
                    )
                    .drawRect(
                        this.scrollbarLeft,
                        this.boxHeight -
                            this.scrollbarSize +
                            this.options.scrollbarOffsetHorizontal,
                        this.scrollbarWidth,
                        this.scrollbarSize
                    )
                    .endFill();
            }
            // this.content.forceHitArea = new PIXI.Rectangle(0, 0 , this.boxWidth, this.boxHeight)
            this.activateFade();
        }

        /**
         * draws mask layer
         * @private
         */
        _drawMask() {
            this._maskContent
                .beginFill(0)
                .drawRect(0, 0, this.boxWidth, this.boxHeight)
                .endFill();
            this.content.mask = this._maskContent;
        }

        /**
         * call when scrollbox content changes
         */
        update() {
            this.content.mask = null;
            this._maskContent.clear();
            if (!this._disabled) {
                this._drawScrollbars();
                this._drawMask();
                if (this.options.dragScroll) {
                    const direction =
                        this.isScrollbarHorizontal && this.isScrollbarVertical
                            ? 'all'
                            : this.isScrollbarHorizontal
                            ? 'x'
                            : 'y';
                    if (direction !== null) {
                        this.content
                            .drag({ clampWheel: true, direction })
                            .clamp({ direction, underflow: this.options.underflow });
                    }
                }
            }
        }

        /**
         * show the scrollbar and restart the timer for fade if options.fade is set
         */
        activateFade() {
            if (this.options.fade) {
                if (this.fade) {
                    this.ease.remove(this.fade);
                }
                this.scrollbar.alpha = 1;
                const time = this.options.fade === true ? 1000 : this.options.fade;
                this.fade = this.ease.to(this.scrollbar, { alpha: 0 }, time, {
                    wait: this.options.fadeWait,
                    ease: this.options.fadeEase
                });
                this.fade.on('each', () => (this.content.dirty = true));
            }
        }

        /**
         * handle pointer down on scrollbar
         * @param {PIXI.interaction.InteractionEvent} e
         * @private
         */
        scrollbarDown(e) {
            const local = this.toLocal(e.data.global);
            if (this.isScrollbarHorizontal) {
                if (local.y > this.boxHeight - this.scrollbarSize) {
                    if (
                        local.x >= this.scrollbarLeft &&
                        local.x <= this.scrollbarLeft + this.scrollbarWidth
                    ) {
                        this.pointerDown = { type: 'horizontal', last: local };
                    } else {
                        if (local.x > this.scrollbarLeft) {
                            this.content.left += this.content.worldScreenWidth;
                            this.update();
                        } else {
                            this.content.left -= this.content.worldScreenWidth;
                            this.update();
                        }
                    }
                    if (this.options.stopPropagation) {
                        e.stopPropagation();
                    }
                    return
                }
            }
            if (this.isScrollbarVertical) {
                if (local.x > this.boxWidth - this.scrollbarSize) {
                    if (
                        local.y >= this.scrollbarTop &&
                        local.y <= this.scrollbarTop + this.scrollbarWidth
                    ) {
                        this.pointerDown = { type: 'vertical', last: local };
                    } else {
                        if (local.y > this.scrollbarTop) {
                            this.content.top += this.content.worldScreenHeight;
                            this.update();
                        } else {
                            this.content.top -= this.content.worldScreenHeight;
                            this.update();
                        }
                    }
                    if (this.options.stopPropagation) {
                        e.stopPropagation();
                    }
                    return
                }
            }
        }

        /**
         * handle pointer move on scrollbar
         * @param {PIXI.interaction.InteractionEvent} e
         * @private
         */
        scrollbarMove(e) {
            if (this.pointerDown) {
                if (this.pointerDown.type === 'horizontal') {
                    const local = this.toLocal(e.data.global);
                    this.content.left += local.x - this.pointerDown.last.x;
                    this.pointerDown.last = local;
                    this.update();
                } else if (this.pointerDown.type === 'vertical') {
                    const local = this.toLocal(e.data.global);
                    this.content.top += local.y - this.pointerDown.last.y;
                    this.pointerDown.last = local;
                    this.update();
                }
                if (this.options.stopPropagation) {
                    e.stopPropagation();
                }
            }
        }

        /**
         * handle pointer down on scrollbar
         * @private
         */
        scrollbarUp() {
            this.pointerDown = null;
        }

        /**
         * resize the mask for the container
         * @param {object} options
         * @param {number} [options.boxWidth] width of scrollbox including scrollbar (in pixels)
         * @param {number} [options.boxHeight] height of scrollbox including scrollbar (in pixels)
         * @param {number} [options.scrollWidth] set the width of the inside of the scrollbox (leave null to use content.width)
         * @param {number} [options.scrollHeight] set the height of the inside of the scrollbox (leave null to use content.height)
         */
        resize(options) {
            this.options.boxWidth =
                typeof options.boxWidth !== 'undefined'
                    ? options.boxWidth
                    : this.options.boxWidth;
            this.options.boxHeight =
                typeof options.boxHeight !== 'undefined'
                    ? options.boxHeight
                    : this.options.boxHeight;
            if (options.scrollWidth) {
                this.scrollWidth = options.scrollWidth;
            }
            if (options.scrollHeight) {
                this.scrollHeight = options.scrollHeight;
            }
            this.content.resize(
                this.options.boxWidth,
                this.options.boxHeight,
                this.scrollWidth,
                this.scrollHeight
            );
            this.update();
        }

        /**
         * ensure that the bounding box is visible
         * @param {number} x - relative to content's coordinate system
         * @param {number} y
         * @param {number} width
         * @param {number} height
         */
        ensureVisible(x, y, width, height) {
            this.content.ensureVisible(x, y, width, height);
            this._drawScrollbars();
        }
    }

    /**
     * Class that represents a PixiJS Scrollview.
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 600,
     *     height: 400
     * }).setup().run()
     *
     * // Create the Scrollview
     * app.loader
     *     .add('elephant', './assets/elephant-1.jpg')
     *     .load((loader, resources) => {
     *         const sprite = new PIXI.Sprite(resources.elephant.texture)
     *         const scrollview = new Scrollview({boxWidth: 400, boxHeight: 300})
     *         scrollview.content.addChild(sprite)
     *         app.scene.addChild(scrollview)
     *
     * @class
     * @extends PIXI.extras.Scrollbox
     * @see {@link https://davidfig.github.io/pixi-scrollbox/jsdoc/Scrollbox.html|Scrollbox}
     * @see {@link https://davidfig.github.io/pixi-viewport/jsdoc/Viewport.html|Viewport}
     */
    class Scrollview extends Scrollbox {
        /**
         * Creates an instance of a Scrollview.
         *
         * @constructor
         * @see https://davidfig.github.io/pixi-scrollbox/jsdoc/Scrollbox.html
         */
        constructor(opts = {}) {
            super(opts);

            this.opts = opts;
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Scrollview} A reference to the Scrollview for chaining.
         */
        setup() {
            return this
        }

        /**
         * Should be called to refresh the layout of the Scrollview. Can be used after resizing.
         *
         * @return {Scrollview} A reference to the Scrollview for chaining.
         */
        layout() {
            this.update();

            return this
        }
    }

    /**
     * Callback for the slider action onStart.
     *
     * @callback onStartCallback
     * @param {object} event - The event object.
     * @param {Slider} slider - A reference to the slider (also this refers to the slider).
     */

    /**
     * Callback for the slider action onUpdate.
     *
     * @callback onUpdateCallback
     * @param {object} event - The event object.
     * @param {Slider} slider - A reference to the slider (also this refers to the slider).
     */

    /**
     * Callback for the slider action onComplete.
     *
     * @callback onCompleteCallback
     * @param {object} event - The event object.
     * @param {Slider} slider - A reference to the slider (also this refers to the slider).
     */

    /**
     * Class that represents a PixiJS Slider.
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 900,
     *     height: 250
     * }).setup().run()
     *
     * // Create the slider
     * const slider = new Slider({
     *     x: 10,
     *     y: 20
     * })
     *
     * // Add the slider to a DisplayObject
     * app.scene.addChild(slider)
     *
     * @class
     * @extends PIXI.Container
     * @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/slider.html|DocTest}
     */
    class Slider extends PIXI.Container {
        /**
         * Creates an instance of a Slider.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the slider.
         * @param {number} [opts.id=auto generated] - The id of the slider.
         * @param {number} [opts.x=0] - The x position of the slider. Can be also set after creation with slider.x = 0.
         * @param {number} [opts.y=0] - The y position of the slider. Can be also set after creation with slider.y = 0.
         * @param {string|Theme} [opts.theme=dark] - The theme to use for this slider. Possible values are dark, light, red
         *     or a Theme object.
         * @param {number} [opts.width=250] - The width of the slider.
         * @param {number} [opts.height=2] - The height of the slider.
         * @param {PIXI.DisplayObject} [opts.container=window.app|object] - The container where the slider events should be attached to.
         * @param {number} [opts.fill=Theme.fill] - The color of the slider background as a hex value.
         * @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
         * @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {number} [opts.controlFill=Theme.stroke] - The color of the slider control background as a hex value.
         * @param {number} [opts.controlFillAlpha=Theme.strokeAlpha] - The alpha value of the background.
         * @param {number} [opts.controlStroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.controlStrokeWidth=Theme.strokeWidth * 0.8] - The width of the border in pixel.
         * @param {number} [opts.controlStrokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {number} [opts.controlRadius=16] - The radius of the slider control.
         * @param {boolean} [opts.disabled=false] - Is the slider disabled? When disabled, the slider has a lower alpha value
         *     and cannot be clicked (interactive is set to false).
         * @param {onStartCallback} [opts.onStart] - Executed when the slider control starts to move.
         * @param {onUpdateCallback} [opts.onUpdate] - Executed when the slider control is moved.
         * @param {onCompleteCallback} [opts.onComplete] - Executed when the slider control was dropped.
         * @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
         *     to display.
         * @param {boolean} [opts.visible=true] - Is the slider initially visible (property visible)?
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    x: 0,
                    y: 0,
                    width: 250,
                    height: 2,
                    container: null,
                    fill: theme.fill,
                    fillAlpha: theme.fillAlpha,
                    stroke: theme.stroke,
                    strokeWidth: theme.strokeWidth,
                    strokeAlpha: theme.strokeAlpha,
                    controlFill: theme.fill,
                    controlFillAlpha: 0.5,
                    controlStroke: theme.primaryColor,
                    controlStrokeWidth: 2,
                    controlStrokeAlpha: theme.strokeAlpha,
                    controlRadius: 16,
                    orientation: 'horizontal',
                    min: 0,
                    max: 100,
                    value: 0,
                    disabled: false,
                    onStart: null,
                    onUpdate: null,
                    onComplete: null,
                    tooltip: null,
                    visible: true
                },
                opts
            );

            this.opts.container = this.opts.container || this;

            // Validation
            //-----------------
            if (this.opts.height > this.opts.width) {
                this.opts.height = this.opts.width;
            }

            if (this.opts.value < this.opts.min) {
                this.opts.value = this.opts.min;
            }

            if (this.opts.value > this.opts.max) {
                this.opts.value = this.opts.max;
            }

            // Properties
            //-----------------
            this.id = this.opts.id;
            this.radius = this.opts.height / 2;

            this._value = this.opts.value;
            this._disabled = null;

            this.sliderObj = null;
            this.control = null;
            this.tooltip = null;

            this.visible = this.opts.visible;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Slider} A reference to the slider for chaining.
         */
        setup() {
            // Container events
            //-----------------
            const container = this.opts.container;

            this.on('pointermove', e => {
                if (this.control.dragging) {
                    const moveX = this.control.event.data.getLocalPosition(
                        this.control.parent
                    ).x;
                    this._value = this.pixelToValue(
                        moveX - this.control.delta - this.opts.controlRadius
                    );
                    let x = this.valueToPixel(this._value) + this.opts.controlRadius;
                    this.control.x = x;

                    if (this.opts.onUpdate) {
                        this.opts.onUpdate.call(this, e, this);
                    }
                }
            });

            if (container instanceof Element) {
                container.addEventListener('pointerup', e => this.onEnd(e), false);
                container.addEventListener(
                    'pointercancel',
                    e => this.onEnd(e),
                    false
                );
                container.addEventListener(
                    'pointerleave',
                    e => this.onEnd(e),
                    false
                );
                container.addEventListener('pointerout', e => this.onEnd(e), false);
                container.addEventListener('mouseup', e => this.onEnd(e), false);
                container.addEventListener('mousecancel', e => this.onEnd(e), false);
                container.addEventListener('mouseleave', e => this.onEnd(e), false);
                container.addEventListener('mouseout', e => this.onEnd(e), false);
            } else {
                container.interactive = true;
                container.on('pointerup', e => this.onEnd(e));
                container.on('pointercancel', e => this.onEnd(e));
                container.on('pointerleave', e => this.onEnd(e));
                container.on('pointerout', e => this.onEnd(e));
            }

            // Slider
            //-----------------
            let sliderObj = new PIXI.Graphics();
            this.sliderObj = sliderObj;
            this.addChild(sliderObj);

            // Control
            //-----------------
            let control = new PIXI.Graphics();
            control.x = this.opts.controlRadius + this.valueToPixel(this.opts.value);
            control.y = this.opts.controlRadius;

            // pointerdown on the control for dragndrop
            control.on('pointerdown', e => {
                control.event = e;
                control.delta = e.data.getLocalPosition(this.control).x;
                control.dragging = true;

                if (this.opts.onStart) {
                    this.opts.onStart.call(this, e, this);
                }
            });

            this.control = control;

            this.addChild(this.control);

            // interaction
            //-----------------
            this.sliderObj.on('pointerover', e => {
                TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
            });

            this.sliderObj.on('pointerout', e => {
                TweenLite.to(this.control, this.theme.fast, { alpha: 1 });
            });

            this.sliderObj.on('pointerdown', e => {
                this.sliderObj.pointerdowned = true;
                TweenLite.to(this.control, this.theme.fast, { alpha: 0.7 });
            });

            // Click on the slider bar
            this.sliderObj.on('pointerup', e => {
                if (this.sliderObj.pointerdowned) {
                    this.sliderObj.pointerdowned = false;
                    const position = e.data.getLocalPosition(this.control.parent);
                    this.value = this.pixelToValue(
                        position.x - this.opts.controlRadius
                    );
                    TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
                }
            });

            // disabled
            //-----------------
            this.disabled = this.opts.disabled;

            // tooltip
            //-----------------
            if (this.opts.tooltip) {
                if (typeof this.opts.tooltip === 'string') {
                    this.tooltip = new Tooltip({
                        object: this,
                        content: this.opts.tooltip
                    });
                } else {
                    this.opts.tooltip.object = this;
                    this.tooltip = new Tooltip(this.opts.tooltip);
                }
            }

            return this
        }

        /**
         * Should be called to refresh the layout of the slider. Can be used after resizing.
         *
         * @return {Slider} A reference to the slider for chaining.
         */
        layout() {
            // set position
            //-----------------
            this.position.set(this.opts.x, this.opts.y);

            // draw
            //-----------------
            this.draw();

            return this
        }

        /**
         * Draws the slider to the canvas.
         *
         * @private
         * @return {Slider} A reference to the slider for chaining.
         */
        draw() {
            const r = this.radius;
            const cr = this.opts.controlRadius;
            const w = this.opts.width;
            const h = this.opts.height;
            const x = cr + r;
            const y = cr + r - h;

            this.sliderObj.clear();
            this.sliderObj.beginFill(0xffffff, 0);
            this.sliderObj.drawRect(0, 0, x + w + cr, cr * 2);
            this.sliderObj.lineStyle(
                this.opts.strokeWidth,
                this.opts.stroke,
                this.opts.strokeAlpha
            );
            this.sliderObj.beginFill(this.opts.fill, this.opts.fillAlpha);
            this.sliderObj.moveTo(x, y);
            this.sliderObj.lineTo(x + w, y);
            this.sliderObj.arcTo(x + w + r, y, x + w + r, y + r, r);
            this.sliderObj.lineTo(x + w + r, y + r + 1); // BUGFIX: If not specified, there is a small area without a stroke.
            this.sliderObj.arcTo(x + w + r, y + h, x + w, y + h, r);
            this.sliderObj.lineTo(x, y + h);
            this.sliderObj.arcTo(x - r, y + h, x - r, y + r, r);
            this.sliderObj.arcTo(x - r, y, x, y, r);
            this.sliderObj.endFill();

            // Draw control
            this.control.clear();
            this.control.lineStyle(
                this.opts.controlStrokeWidth,
                this.opts.controlStroke,
                this.opts.controlStrokeAlpha
            );
            this.control.beginFill(
                this.opts.controlFill,
                this.opts.controlFillAlpha
            );
            this.control.drawCircle(0, 0, cr - 1);
            this.control.beginFill(
                this.opts.controlStroke,
                this.opts.controlStrokeAlpha
            );
            this.control.drawCircle(0, 0, cr / 6);
            this.control.endFill();

            return this
        }

        /**
         * Executed, when the slider control movement ended.
         *
         * @private
         * @return {Slider} A reference to the slider for chaining.
         */
        onEnd(e) {
            if (this.control.dragging) {
                this.control.event = null;
                this.control.dragging = false;
                if (this.opts.onComplete) {
                    this.opts.onComplete.call(this, e, this);
                }
            }

            return this
        }

        /**
         * Calculates the value for a given pixel.
         *
         * @private
         * @param {number} value
         * @returns  {number} The calucalted pixel.
         */
        valueToPixel(value) {
            if (value < this.opts.min) {
                value = this.opts.min;
            } else if (value > this.opts.max) {
                value = this.opts.max;
            }
            return (
                (this.opts.width * (value - this.opts.min)) /
                (this.opts.max - this.opts.min)
            )
        }

        /**
         * Calculates the pixel for a given value.
         *
         * @private
         * @param {number} pixel
         * @returns {number} The calucalted value.
         */
        pixelToValue(pixel) {
            if (pixel < 0) {
                pixel = 0;
            } else if (pixel > this.opts.width) {
                pixel = this.opts.width;
            }
            return (
                this.opts.min +
                ((this.opts.max - this.opts.min) * pixel) / this.opts.width
            )
        }

        /**
         * Gets or sets the value.
         *
         * @member {number}
         */
        get value() {
            return Math.round(this._value)
        }
        set value(value) {
            if (value < this.opts.min) {
                value = this.opts.min;
            } else if (value > this.opts.max) {
                value = this.opts.max;
            }
            this._value = value;

            const x = this.valueToPixel(value) + this.opts.controlRadius;

            TweenLite.to(this.control, this.theme.fast, { x });
        }

        /**
         * Gets or sets the disabled state. When disabled, the slider cannot be clicked.
         *
         * @member {boolean}
         */
        get disabled() {
            return this._disabled
        }
        set disabled(value) {
            this._disabled = value;

            if (this._disabled) {
                this.interactive = false;
                this.sliderObj.interactive = false;
                this.control.interactive = false;
                this.control.buttonMode = false;
                this.alpha = 0.5;
            } else {
                this.interactive = true;
                this.sliderObj.interactive = true;
                this.control.interactive = true;
                this.control.buttonMode = true;
                this.alpha = 1;
            }
        }

        /**
         * Shows the slider (sets his alpha values to 1).
         *
         * @return {Slider} A reference to the slider for chaining.
         */
        show() {
            this.opts.strokeAlpha = 1;
            this.opts.fillAlpha = 1;
            this.opts.controlStrokeAlpha = 1;
            this.opts.controlFillAlpha = 1;

            this.layout();

            return this
        }

        /**
         * Hides the slider (sets his alpha values to 1).
         *
         * @return {Slider} A reference to the slider for chaining.
         */
        hide() {
            this.opts.strokeAlpha = 0;
            this.opts.fillAlpha = 0;
            this.opts.controlStrokeAlpha = 0;
            this.opts.controlFillAlpha = 0;

            this.layout();

            return this
        }
    }

    /* eslint-disable no-undef */

    class StylusCommand extends Object {
        constructor() {
            super();
        }

        do(stylus) {
            stylus.commandStack.push(this);
        }

        undo(stylus) {
            stylus.undoCommandStack.push(this);
        }

        redo(stylus) {
            this.do(stylus);
        }
    }

    class StrokeCommand extends StylusCommand {
        constructor(stroke) {
            super();
            this.stroke = stroke;
        }

        do(stylus) {
            if (this.stroke.length > 0) {
                super.do(stylus);
                stylus.stroke = [];
                stylus.strokes.push(this.stroke);
                stylus.redraw();
                stylus.changed();
            }
        }

        undo(stylus) {
            if (this.stroke.length > 0) {
                super.undo(stylus);
                stylus.strokes.pop();
                stylus.redraw();
                stylus.changed();
            }
        }
    }

    class ClearCommand extends StylusCommand {
        do(stylus) {
            // Clears the command stack
            stylus.commandStack = [];
            super.do(stylus);
            this.strokes = stylus.strokes;
            stylus.stroke = [];
            stylus.strokes = [];
            stylus.redraw();
            stylus.changed();
        }

        undo(stylus) {
            //super.undo(stylus) // Clear all is not redoable
            stylus.stroke = [];
            stylus.strokes = this.strokes;
            stylus.redraw();
            stylus.changed();
        }
    }

    class Stylus extends PIXI.Graphics {
        constructor({
            width = window.innerWidth,
            height = window.innerHeight,
            interactive = true,
            color = 0x000000,
            tiltX = 0,
            tiltY = 0,
            backgroundAlpha = 1,
            backgroundFill = 0xffffff,
            colorAlpha = 1,
            captureEvents = true,
            acceptMouseEvents = true
        } = {}) {
            super();
            this.activePointers = 0;
            this.wantedWidth = width;
            this.wantedHeight = height;
            this.backgroundAlpha = backgroundAlpha;
            this.backgroundFill = backgroundFill;
            this.colorAlpha = colorAlpha;
            this.color = color;
            this.interactive = interactive;
            this.debug = false;
            this.tiltX = tiltX; // degrees -90 ... 90
            this.tiltY = tiltY; // degrees -90 ... 90
            this.captureEvents = captureEvents;
            this.commandStack = [];
            this.undoCommandStack = [];
            this.strokes = [];
            this.stroke = [];
            this.minStrokeLength = 4;
            if (captureEvents) this.registerEventHandler(acceptMouseEvents);
            this.drawBackground();
        }

        drawBackground() {
            this.clear();
            this.beginFill(this.backgroundFill, this.backgroundAlpha);
            this.drawRect(0, 0, this.wantedWidth, this.wantedHeight);
            this.endFill();
        }

        touchToPoint(t) {
            return { x: t.clientX, y: t.clientY }
        }

        isStylusPointer(event) {
            let identifier = event.data.identifier;
            if (typeof event.data.originalEvent.changedTouches !== 'undefined') {
                for (let touch of event.data.originalEvent.changedTouches) {
                    if (
                        touch.identifier === identifier &&
                        touch.touchType === 'stylus'
                    ) {
                        this.tiltX = Angle.radian2degree(touch.azimuthAngle);
                        this.tiltY = 90.0 - Angle.radian2degree(touch.altitudeAngle);
                        return true
                    }
                }
            }
            // UO: Not tested since the Sprot delivered "mouse" events to Chrome
            if (event.data.originalEvent.pointerType === 'pen') {
                this.tiltX = event.data.originalEvent.tiltX;
                this.tiltY = event.data.originalEvent.tiltY;
                return true
            }
            return false
        }

        isStylusTouch(event) {
            let identifier = event.data.identifier;
            if (typeof event.data.originalEvent.changedTouches !== 'undefined') {
                for (let touch of event.data.originalEvent.changedTouches) {
                    if (
                        touch.identifier === identifier &&
                        touch.pointerType === 'touch'
                    ) {
                        return true
                    }
                }
            }
            return false
        }

        getPointerID(event) {
            let identifier = event.data.identifier;
            for (let touch of event.data.originalEvent.changedTouches) {
                if (touch.identifier === identifier) {
                    return touch.pointerId
                }
            }
        }

        singlePointer() {
            return this.activePointers == 1
        }

        registerEventHandler() {
            window.addEventListener('keydown', e => {
                switch (e.keyCode) {
                    case 38: // up arrow
                        this.tiltX += 5;
                        break
                    case 40: // down arrow
                        this.tiltX -= 5;
                        break
                    case 37: // left arrow
                        this.tiltY -= 5;
                        break
                    case 39: // right arrow
                        this.tiltY += 5;
                        break
                }
                if (this.debug)
                    console.log('keydown', e.keyCode, this.tiltX, this.tiltY);
            });

            this.on('pointerdown', e => {
                if (this.debug) console.log('pointerdown', e);
                if (this.eventInside(e)) {
                    this.activePointers += 1;
                    if (this.singlePointer()) {
                        this.startStroke(this.toStroke(e));
                    }
                }
            });

            this.on('pointermove', e => {
                if (
                    Events$1.isPointerDown(e.data.originalEvent) ||
                    this.isStylusPointer(e) ||
                    this.isStylusTouch(e)
                ) {
                    if (this.debug)
                        console.log('pointermove', e, this.eventInside(e));
                    if (this.eventInside(e) && this.singlePointer())
                        this.moveStroke(this.toStroke(e));
                }
            });
            this.on('pointerup', e => {
                if (this.eventInside(e)) {
                    if (this.activePointers > 0) {
                        this.activePointers -= 1;
                        this.endStroke(this.toStroke(e));
                    }
                }
                if (this.debug) console.log('pointerup', this.activePointers);
            });
            this.on('pointerleave', e => {
                if (this.activePointers > 0) {
                    this.activePointers -= 1;
                }
                this.endStroke(this.toStroke(e));
            });
            this.on('pointercancel', e => {
                if (this.activePointers > 0) {
                    this.activePointers -= 1;
                }
                this.endStroke(this.toStroke(e));
            });
        }

        undoable() {
            return this.commandStack.length > 0
        }

        redoable() {
            return this.undoCommandStack.length > 0
        }

        undo() {
            if (this.undoable()) {
                let cmd = this.commandStack.pop();
                cmd.undo(this);
            }
        }

        redo() {
            if (this.redoable()) {
                let cmd = this.undoCommandStack.pop();
                cmd.redo(this);
            }
        }

        eventInside(event) {
            let local = this.toLocal(event.data.global);
            for (let child of this.children) {
                let r = child.getBounds();
                if (r.contains(local.x, local.y)) {
                    console.log('Child touched');
                    return false
                }
            }
            if (local.x < 0 || local.x > this.wantedWidth) return false
            if (local.y < 0 || local.y > this.wantedHeight) return false
            event.stopPropagation();
            // if (this.debug) console.log('stopPropagation', event)
            if (event.data.originalEvent.claimedByScatter) {
                return false
            }
            return true
        }

        toLocalPoint(event) {
            return this.toLocal(event.data.global)
        }

        toStroke(event) {
            let local = this.toLocalPoint(event);
            let x = Math.max(0, Math.min(local.x, this.wantedWidth));
            let y = Math.max(0, Math.min(local.y, this.wantedHeight));
            let desc = {
                x,
                y,
                pressure: event.pressure || null,
                tiltX: this.tiltX,
                tiltY: this.tiltY,
                color: this.color
            };
            return desc
        }

        startStroke(info) {
            this.stroke = [info];
            this.redraw();
        }

        moveStroke(info) {
            this.stroke.push(info);
            this.redraw();
        }

        // eslint-disable-next-line no-unused-vars
        endStroke(info) {
            if (this.stroke.length >= this.minStrokeLength) {
                let cmd = new StrokeCommand(this.stroke);
                cmd.do(this);
            }
        }

        tiltToLineWidth(value) {
            return Math.round(Math.abs(value / 10) + 1)
        }

        drawStroke(stroke) {
            if (stroke.length) {
                let start = stroke[0];
                this.beginFill(0, 0);
                this.moveTo(start.x, start.y);
                for (let i = 1; i < stroke.length; i++) {
                    let info = stroke[i];
                    this.lineStyle(
                        this.tiltToLineWidth(info.tiltY),
                        info.color,
                        this.colorAlpha
                    );
                    this.lineTo(info.x, info.y);
                }
                this.endFill();
            }
        }

        drawTouch(point) {
            this.beginFill(0, 0);
            this.drawCircle(point.x, point.y, 22);
            this.endFill();
        }

        drawStrokes() {
            this.drawBackground();
            this.lineStyle(1.0, 0xff0000, 1);
            for (let stroke of this.iterStrokes()) {
                this.drawStroke(stroke);
            }
        }

        redraw() {
            this.drawStrokes();
        }

        // Can be overwritten if different levels of strokes are necessary
        *iterStrokes() {
            for (let stroke of this.strokes) {
                yield stroke;
            }
            yield this.stroke;
        }

        changed() {
            // Can be overwritten
        }

        clearAll() {
            let cmd = new ClearCommand();
            cmd.do(this);
        }

        normalizeInfo(info) {
            let { x, y, pressure, tiltX, tiltY, color } = info;
            x /= this.wantedWidth;
            y /= this.wantedHeight;
            return { x, y, pressure, tiltX, tiltY, color }
        }

        denormalizeInfo(info) {
            let { x, y, pressure, tiltX, tiltY, color } = info;
            x = x * this.wantedWidth;
            y = y * this.wantedHeight;
            return { x, y, pressure, tiltX, tiltY, color }
        }

        // Convert strokes into an object that can be stored in an Indexed DB.
        // Returns normalized strokes
        toObject() {
            let result = [];
            for (let stroke of this.strokes) {
                let normalized = [];
                for (let info of stroke) {
                    normalized.push(this.normalizeInfo(info));
                }
                result.push(normalized);
            }
            return result
        }

        // Read normalized strokes from an object from an Indexed DB.
        fromObject(normalizedStrokes) {
            this.strokes = [];
            for (let stroke of normalizedStrokes) {
                let denormalized = [];
                for (let info of stroke) {
                    denormalized.push(this.denormalizeInfo(info));
                }
                this.strokes.push(denormalized);
            }
        }

        // Convert strokes into a JSON object that can be stored in an Indexed DB
        toJSON() {
            return JSON.stringify(this.toObject())
        }

        // Convert strokes from a JSON
        fromJSON(json) {
            this.fromObject(JSON.parse(json));
        }

        // Returns a set of used colors
        usedColors() {
            let used = new Set();
            for (let info of this.stroke) {
                used.add(info.color);
            }
            for (let stroke of this.strokes) {
                for (let info of stroke) {
                    used.add(info.color);
                }
            }
            return used.values()
        }
    }

    /**
     * Callback for the switch action.
     *
     * @callback actionCallback
     * @param {object} event - The event object.
     * @param {Switch} switch - A reference to the switch (also this refers to the switch).
     */

    /**
     * Callback for the switch action.
     *
     * @callback actionActiveCallback
     * @param {object} event - The event object.
     * @param {Switch} switch - A reference to the switch (also this refers to the switch).
     */

    /**
     * Callback for the switch beforeAction.
     *
     * @callback beforeActionCallback
     * @param {object} event - The event object.
     * @param {Switch} switch - A reference to the switch (also this refers to the switch).
     */

    /**
     * Callback for the switch afterAction.
     *
     * @callback afterActionCallback
     * @param {object} event - The event object.
     * @param {Switch} switch - A reference to the switch (also this refers to the switch).
     */

    /**
     * Class that represents a PixiJS Switch.
     *
     * @example
     * // Create the app
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 900,
     *     height: 250
     * }).setup().run()
     *
     * // Create the switch
     * const switch1 = new Switch({
     *     x: 10,
     *     y: 20
     * })
     *
     * // Add the switch to a DisplayObject
     * app.scene.addChild(switch1)
     *
     * @class
     * @extends PIXI.Container
     * @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PIXI.Container}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/switch.html|DocTest}
     */
    class Switch extends PIXI.Container {
        /**
         * Creates an instance of a Switch.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the switch.
         * @param {number} [opts.id=auto generated] - The id of the switch.
         * @param {number} [opts.x=0] - The x position of the switch. Can be also set after creation with switch.x = 0.
         * @param {number} [opts.y=0] - The y position of the switch. Can be also set after creation with switch.y = 0.
         * @param {string|Theme} [opts.theme=dark] - The theme to use for this switch. Possible values are dark, light, red
         *     or a Theme object.
         * @param {number} [opts.width=44] - The width of the switch.
         * @param {number} [opts.height=28] - The height of the switch.
         * @param {number} [opts.fill=Theme.fill] - The color of the switch background as a hex value.
         * @param {number} [opts.fillAlpha=Theme.fillAlpha] - The alpha value of the background.
         * @param {number} [opts.fillActive=Theme.fillActive] - The color of the switch background when activated.
         * @param {number} [opts.fillActiveAlpha=Theme.fillActiveAlpha] - The alpha value of the background when activated.
         * @param {number} [opts.stroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.strokeWidth=Theme.strokeWidth] - The width of the border in pixel.
         * @param {number} [opts.strokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {number} [opts.strokeActive=Theme.strokeActive] - The color of the border when activated.
         * @param {number} [opts.strokeActiveWidth=Theme.strokeActiveWidth] - The width of the border in pixel when activated.
         * @param {number} [opts.strokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
         * @param {number} [opts.controlFill=Theme.stroke] - The color of the switch control background as a hex value.
         * @param {number} [opts.controlFillAlpha=Theme.strokeAlpha] - The alpha value of the background.
         * @param {number} [opts.controlFillActive=Theme.stroke] - The color of the switch control background when activated.
         * @param {number} [opts.controlFillActiveAlpha=Theme.strokeAlpha] - The alpha value of the background when activated.
         * @param {number} [opts.controlStroke=Theme.stroke] - The color of the border as a hex value.
         * @param {number} [opts.controlStrokeWidth=Theme.strokeWidth * 0.8] - The width of the border in pixel.
         * @param {number} [opts.controlStrokeAlpha=Theme.strokeAlpha] - The alpha value of the border.
         * @param {number} [opts.controlStrokeActive=Theme.stroke] - The color of the border when activated.
         * @param {number} [opts.controlStrokeActiveWidth=Theme.strokeActiveWidth * 0.8] - The width of the border in pixel when activated.
         * @param {number} [opts.controlStrokeActiveAlpha=Theme.strokeActiveAlpha] - The alpha value of the border when activated.
         * @param {number} [opts.duration=Theme.fast] - The duration of the animation when the switch gets activated in seconds.
         * @param {number} [opts.durationActive=Theme.fast] - The duration of the animation when the switch gets deactivated in seconds.
         * @param {boolean} [opts.disabled=false] - Is the switch disabled? When disabled, the switch has a lower alpha value
         *     and cannot be clicked (interactive is set to false).
         * @param {boolean} [opts.active=false] - Is the button initially active?
         * @param {actionCallback} [opts.action] - Executed when the switch was triggered in inactive state (by pointerup).
         * @param {actionActiveCallback} [opts.actionActive] - Executed when the button was triggered in active state (by pointerup).
         * @param {beforeActionCallback} [opts.beforeAction] - Executed before an action is triggered.
         * @param {afterActionCallback} [opts.afterAction] - Executed after an action was triggered.
         * @param {string|object} [opts.tooltip] - A string for the label of the tooltip or an object to configure the tooltip
         *     to display.
         * @param {boolean} [opts.visible=true] - Is the switch initially visible (property visible)?
         */
        constructor(opts = {}) {
            super();

            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    x: 0,
                    y: 0,
                    width: 44,
                    height: 28,
                    fill: theme.fill,
                    fillAlpha: theme.fillAlpha,
                    fillActive: theme.primaryColor,
                    fillActiveAlpha: theme.fillActiveAlpha,
                    stroke: theme.stroke,
                    strokeWidth: theme.strokeWidth,
                    strokeAlpha: theme.strokeAlpha,
                    strokeActive: theme.primaryColor,
                    strokeActiveWidth: theme.strokeActiveWidth,
                    strokeActiveAlpha: theme.strokeActiveAlpha,
                    controlFill: theme.stroke,
                    controlFillAlpha: theme.strokeAlpha,
                    controlFillActive: theme.stroke,
                    controlFillActiveAlpha: theme.strokeAlpha,
                    controlStroke: theme.stroke,
                    controlStrokeWidth: theme.strokeWidth * 0.8,
                    controlStrokeAlpha: theme.strokeAlpha,
                    controlStrokeActive: theme.stroke,
                    controlStrokeActiveWidth: theme.strokeActiveWidth * 0.8,
                    controlStrokeActiveAlpha: theme.strokeActiveAlpha,
                    duration: theme.fast,
                    durationActive: theme.fast,
                    disabled: false,
                    active: false,
                    action: null,
                    actionActive: null,
                    beforeAction: null,
                    afterAction: null,
                    tooltip: null,
                    visible: true
                },
                opts
            );

            this.opts.controlRadius =
                this.opts.controlRadius || this.opts.height / 2;
            this.opts.controlRadiusActive =
                this.opts.controlRadiusActive || this.opts.controlRadius;

            // Validation
            //-----------------
            if (this.opts.height > this.opts.width) {
                this.opts.height = this.opts.width;
            }

            // Properties
            //-----------------
            this.id = this.opts.id;
            this.radius = this.opts.height / 2;

            this._active = null;
            this._disabled = null;

            this.switchObj = null;
            this.control = null;
            this.tooltip = null;

            this.visible = this.opts.visible;

            // animated
            //-----------------
            this.tempAnimated = {
                fill: this.opts.fill,
                fillAlpha: this.opts.fillAlpha,
                stroke: this.opts.stroke,
                strokeWidth: this.opts.strokeWidth,
                strokeAlpha: this.opts.strokeAlpha,
                controlFill: this.opts.controlFill,
                controlFillAlpha: this.opts.controlFillAlpha,
                controlStroke: this.opts.controlStroke,
                controlStrokeWidth: this.opts.controlStrokeWidth,
                controlStrokeAlpha: this.opts.controlStrokeAlpha,
                controlRadius: this.opts.controlRadius
            };

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Switch} A reference to the switch for chaining.
         */
        setup() {
            // Switch
            //-----------------
            let switchObj = new PIXI.Graphics();
            this.switchObj = switchObj;
            this.addChild(switchObj);

            // Control
            //-----------------
            this.xInactive = this.opts.controlRadius;
            this.xActive = this.opts.width - this.opts.controlRadiusActive;

            let control = new PIXI.Graphics();
            control.x = this.opts.active ? this.xActive : this.xInactive;
            control.y = this.opts.height / 2;

            this.control = control;

            this.addChild(this.control);

            // interaction
            //-----------------
            this.switchObj.on('pointerover', e => {
                TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });
            });

            this.switchObj.on('pointerout', e => {
                TweenLite.to(this.control, this.theme.fast, { alpha: 1 });
            });

            this.switchObj.on('pointerdown', e => {
                TweenLite.to(this.control, this.theme.fast, { alpha: 0.7 });
            });

            this.switchObj.on('pointerup', e => {
                if (this.opts.beforeAction) {
                    this.opts.beforeAction.call(this, e, this);
                }

                this.active = !this.active;

                if (this.active) {
                    if (this.opts.action) {
                        this.opts.action.call(this, e, this);
                    }
                } else {
                    if (this.opts.actionActive) {
                        this.opts.actionActive.call(this, e, this);
                    }
                }

                TweenLite.to(this.control, this.theme.fast, { alpha: 0.83 });

                if (this.opts.afterAction) {
                    this.opts.afterAction.call(this, e, this);
                }
            });

            // disabled
            //-----------------
            this.disabled = this.opts.disabled;

            // active
            //-----------------
            this.active = this.opts.active;

            // tooltip
            //-----------------
            if (this.opts.tooltip) {
                if (typeof this.opts.tooltip === 'string') {
                    this.tooltip = new Tooltip({
                        object: this,
                        content: this.opts.tooltip
                    });
                } else {
                    this.opts.tooltip.object = this;
                    this.tooltip = new Tooltip(this.opts.tooltip);
                }
            }

            return this
        }

        /**
         * Should be called to refresh the layout of the switch. Can be used after resizing.
         *
         * @return {Switch} A reference to the switch for chaining.
         */
        layout() {
            // set position
            //-----------------
            this.position.set(this.opts.x, this.opts.y);

            // draw
            //-----------------
            this.draw();

            return this
        }

        /**
         * Draws the switch to the canvas.
         *
         * @private
         * @return {Switch} A reference to the switch for chaining.
         */
        draw() {
            this.switchObj.clear();
            if (this.active) {
                this.switchObj.lineStyle(
                    this.opts.strokeActiveWidth,
                    this.opts.strokeActive,
                    this.opts.strokeActiveAlpha
                );
                this.switchObj.beginFill(
                    this.opts.fillActive,
                    this.opts.fillActiveAlpha
                );
            } else {
                this.switchObj.lineStyle(
                    this.opts.strokeWidth,
                    this.opts.stroke,
                    this.opts.strokeAlpha
                );
                this.switchObj.beginFill(this.opts.fill, this.opts.fillAlpha);
            }
            this.switchObj.moveTo(this.radius, 0);
            this.switchObj.lineTo(this.opts.width - this.radius, 0);
            this.switchObj.arcTo(
                this.opts.width,
                0,
                this.opts.width,
                this.radius,
                this.radius
            );
            this.switchObj.lineTo(this.opts.width, this.radius + 1); // BUGFIX: If not specified, there is a small area without a stroke.
            this.switchObj.arcTo(
                this.opts.width,
                this.opts.height,
                this.opts.width - this.radius,
                this.opts.height,
                this.radius
            );
            this.switchObj.lineTo(this.radius, this.opts.height);
            this.switchObj.arcTo(0, this.opts.height, 0, this.radius, this.radius);
            this.switchObj.arcTo(0, 0, this.radius, 0, this.radius);
            this.switchObj.endFill();

            // Draw control
            this.control.clear();
            if (this.active) {
                this.control.lineStyle(
                    this.opts.controlStrokeActiveWidth,
                    this.opts.controlStrokeActive,
                    this.opts.controlStrokeActiveAlpha
                );
                this.control.beginFill(
                    this.opts.controlFillActive,
                    this.opts.controlFillActiveAlpha
                );
                this.control.drawCircle(0, 0, this.opts.controlRadiusActive - 1);
            } else {
                this.control.lineStyle(
                    this.opts.controlStrokeWidth,
                    this.opts.controlStroke,
                    this.opts.controlStrokeAlpha
                );
                this.control.beginFill(
                    this.opts.controlFill,
                    this.opts.controlFillAlpha
                );
                this.control.drawCircle(0, 0, this.opts.controlRadius - 1);
            }
            this.control.endFill();

            return this
        }

        /**
         * Draws the animation.
         *
         * @private
         * @return {Switch} A reference to the switch for chaining.
         */
        drawAnimated() {
            this.switchObj.clear();
            this.switchObj.lineStyle(
                this.tempAnimated.strokeWidth,
                this.tempAnimated.stroke,
                this.tempAnimated.strokeAlpha
            );
            this.switchObj.beginFill(
                this.tempAnimated.fill,
                this.tempAnimated.fillAlpha
            );
            this.switchObj.moveTo(this.radius, 0);
            this.switchObj.lineTo(this.opts.width - this.radius, 0);
            this.switchObj.arcTo(
                this.opts.width,
                0,
                this.opts.width,
                this.radius,
                this.radius
            );
            this.switchObj.lineTo(this.opts.width, this.radius + 1); // BUGFIX: If not specified, there is a small area without a stroke.
            this.switchObj.arcTo(
                this.opts.width,
                this.opts.height,
                this.opts.width - this.radius,
                this.opts.height,
                this.radius
            );
            this.switchObj.lineTo(this.radius, this.opts.height);
            this.switchObj.arcTo(0, this.opts.height, 0, this.radius, this.radius);
            this.switchObj.arcTo(0, 0, this.radius, 0, this.radius);
            this.switchObj.endFill();

            this.control.clear();
            this.control.lineStyle(
                this.tempAnimated.controlStrokeWidth,
                this.tempAnimated.controlStroke,
                this.tempAnimated.controlStrokeAlpha
            );
            this.control.beginFill(
                this.tempAnimated.controlFill,
                this.tempAnimated.controlFillAlpha
            );
            this.control.drawCircle(0, 0, this.tempAnimated.controlRadius - 1);
            this.control.endFill();

            return this
        }

        /**
         * Gets or sets the active state.
         *
         * @member {boolean}
         */
        get active() {
            return this._active
        }

        set active(value) {
            this._active = value;

            if (this._active) {
                TweenLite.to(this.control, this.opts.duration, { x: this.xActive });
                TweenLite.to(this.tempAnimated, this.opts.duration, {
                    colorProps: {
                        fill: this.opts.fillActive,
                        stroke: this.opts.strokeActive,
                        controlFill: this.opts.controlFillActive,
                        controlStroke: this.opts.controlStrokeActive,
                        format: 'number'
                    },
                    fillAlpha: this.opts.fillActiveAlpha,
                    strokeWidth: this.opts.strokeActiveWidth,
                    strokeAlpha: this.opts.strokeActiveAlpha,
                    controlFillAlpha: this.opts.controlFillActiveAlpha,
                    controlStrokeWidth: this.opts.controlStrokeActiveWidth,
                    controlStrokeAlpha: this.opts.controlStrokeActiveAlpha,
                    controlRadius: this.opts.controlRadiusActive,
                    onUpdate: () => this.drawAnimated(),
                    onComplete: () => this.draw()
                });
            } else {
                TweenLite.to(this.control, this.opts.durationActive, {
                    x: this.xInactive
                });
                TweenLite.to(this.tempAnimated, this.opts.durationActive, {
                    colorProps: {
                        fill: this.opts.fill,
                        stroke: this.opts.stroke,
                        controlFill: this.opts.controlFill,
                        controlStroke: this.opts.controlStroke,
                        format: 'number'
                    },
                    fillAlpha: this.opts.fillAlpha,
                    strokeWidth: this.opts.strokeWidth,
                    strokeAlpha: this.opts.strokeAlpha,
                    controlFillAlpha: this.opts.controlFillAlpha,
                    controlStrokeWidth: this.opts.controlStrokeWidth,
                    controlStrokeAlpha: this.opts.controlStrokeAlpha,
                    controlRadius: this.opts.controlRadius,
                    onUpdate: () => this.drawAnimated(),
                    onComplete: () => this.draw()
                });
            }
        }

        /**
         * Gets or sets the disabled state. When disabled, the switch cannot be clicked.
         *
         * @member {boolean}
         */
        get disabled() {
            return this._disabled
        }

        set disabled(value) {
            this._disabled = value;

            if (this._disabled) {
                this.switchObj.interactive = false;
                this.switchObj.buttonMode = false;
                this.switchObj.alpha = 0.5;
                this.control.alpha = 0.5;
            } else {
                this.switchObj.interactive = true;
                this.switchObj.buttonMode = true;
                this.switchObj.alpha = 1;
                this.control.alpha = 1;
            }
        }

        /**
         * Shows the switch (sets his alpha values to 1).
         *
         * @return {Switch} A reference to the switch for chaining.
         */
        show() {
            this.opts.strokeAlpha = 1;
            this.opts.strokeActiveAlpha = 1;
            this.opts.fillAlpha = 1;
            this.opts.fillActiveAlpha = 1;
            this.opts.controlStrokeAlpha = 1;
            this.opts.controlStrokeActiveAlpha = 1;
            this.opts.controlFillAlpha = 1;
            this.opts.controlFillActiveAlpha = 1;

            this.layout();

            return this
        }

        /**
         * Hides the switch (sets his alpha values to 1).
         *
         * @return {Switch} A reference to the switch for chaining.
         */
        hide() {
            this.opts.strokeAlpha = 0;
            this.opts.strokeActiveAlpha = 0;
            this.opts.fillAlpha = 0;
            this.opts.fillActiveAlpha = 0;
            this.opts.controlStrokeAlpha = 0;
            this.opts.controlStrokeActiveAlpha = 0;
            this.opts.controlFillAlpha = 0;
            this.opts.controlFillActiveAlpha = 0;

            this.layout();

            return this
        }
    }

    /**
     * Class that represents a PixiJS PopupMenu.
     *
     * @example
     * // Create the button and the modal when clicked
     * const button = new Button({
     *     label: 'Show PopupMenu',
     *     action: e => {
     *         const popupmenu = new PopupMenu({
     *             items: [
     *                 {label: 'Save', action: () => alert('Saved')},
     *                 {label: 'Edit', action: () => alert('Edited')},
     *                 {label: 'Delete', action: () => alert('Deleted')}
     *             ]
     *         })
     *         app.scene.addChild(popupmenu)
     *     }
     * })
     *
     * // Add the button to a DisplayObject
     * app.scene.addChild(button)
     *
     * @class
     * @extends Popup
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/popupmenu.html|DocTest}
     */
    class PopupMenu extends Popup {
        /**
         * Creates an instance of a PopupMenu.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the modal.
         * @param {object[]} [opts.items=[]] - A list of the menu items. Each item must be of type object.
         *     If an object has a label property, a PIXI.Text object is created (using the textStyle property).
         *     If an object hasn't a label property, it must contain a content property which has to be a
         *     PIXI.DisplayObject.
         * @param {number} [opts.margin=Theme.margin / 2] - The app where the modal belongs to.
         * @param {object} [opts.textStyle=Theme.textStyle] - The color of the background.
         * @param {boolean} [opts.closeOnPopup=true] - The opacity of the background.
         */
        constructor(opts = {}) {
            const theme = Theme.fromString(opts.theme);

            opts = Object.assign(
                {},
                {
                    items: [],
                    margin: theme.margin / 2,
                    textStyle: theme.textStyle,
                    closeOnPopup: true
                },
                opts
            );

            super(opts);
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {PopupMenu} A reference to the popupmenu for chaining.
         */
        setup() {
            // content
            //-----------------
            const content = new PIXI.Container();

            let y = 0;
            for (let item of this.opts.items) {
                let object = null;

                if (item.label) {
                    object = new PIXI.Text(
                        item.label,
                        item.textStyle || this.opts.textStyle
                    );
                } else {
                    object = item.content;
                }

                object.y = y;

                if (item.action) {
                    if (item.disabled) {
                        object.alpha = 0.5;
                    } else {
                        object.interactive = true;
                        object.buttonMode = true;
                    }
                    object.on('pointerover', e => {
                        TweenLite.to(object, this.theme.fast, {
                            alpha: 0.83,
                            overwrite: 'none'
                        });
                    });
                    object.on('pointerout', e => {
                        TweenLite.to(object, this.theme.fast, {
                            alpha: 1,
                            overwrite: 'none'
                        });
                    });
                    object.on('pointerup', e => {
                        item.action.call(object, e, object);
                        if (this.opts.closeOnAction) {
                            this.hide();
                        }
                    });
                }

                content.addChild(object);

                y += object.height + this.opts.margin;
            }

            this.opts.content = content;

            super.setup();
        }
    }

    /* global Quad */

    /**
     * Class that represents a PixiJS Volatile.
     *
     * @example
     * const app = new PIXIApp({
     *     view: canvas,
     *     width: 900,
     *     height: 250
     * }).setup().run()
     *
     * const button = new Button({
     *     label: 'Volatile!',
     *     action: () => {
     *         new Volatile({
     *             object: button,
     *             direction: 'right',
     *             destroyOnComplete: false
     *         })
     *     }
     * })
     *
     * app.scene.addChild(button)
     *
     * @class
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/volatile.html|DocTest}
     */
    class Volatile {
        /**
         * Creates an instance of a Volatile.
         *
         * @constructor
         * @param {object} [opts] - An options object to specify to style and behaviour of the modal.
         * @param {number} [opts.id=auto generated] - The id of the tooltip.
         * @param {PIXI.DisplayObject|PIXI.DisplayObject[]} opts.object - The object where the volatile should be applied to.
         * @param {string} [opts.direction=top] - The animation direction. Possible values: top, right, bottom, left.
         * @param {function} [opts.onStart] - A function which is executed when the volatile animation starts.
         * @param {function} [opts.onComplete] - A function which is executed when the volatile animation finishes.
         * @param {number} [opts.distance=160] - The animation distance.
         * @param {number} [opts.duration=1.5] - The duration of the animation in seconds.
         * @param {object} [opts.ease=Quad.easeOut] - The easing of the animation, see {@link https://greensock.com/docs/Easing}
         * @param {boolean} [opts.destroyOnComplete=true] - Should the object be destroyed after the volatile animation?
         */
        constructor(opts = {}) {
            const theme = Theme.fromString(opts.theme);
            this.theme = theme;

            this.opts = Object.assign(
                {},
                {
                    id: PIXI.utils.uid(),
                    object: null,
                    direction: 'top', // top, right, bottom, left
                    onStart: null,
                    onComplete: null,
                    distance: 160,
                    duration: 1.5,
                    ease: Quad.easeOut,
                    destroyOnComplete: true
                },
                opts
            );

            this.id = this.opts.id;

            if (!Array.isArray(this.opts.object)) {
                this.opts.object = [this.opts.object];
            }

            this.objects = this.opts.object;

            // setup
            //-----------------
            this.setup();

            // layout
            //-----------------
            this.layout();

            // run
            //-----------------
            this.run();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {Volatile} A reference to the volatile for chaining.
         */
        setup() {
            return this
        }

        /**
         * Should be called to refresh the layout of the volatile. Can be used after resizing.
         *
         * @return {Volatile} A reference to the volatile for chaining.
         */
        layout() {
            return this
        }

        /**
         * Starts the volatile animation.
         *
         * @private
         * @return {Volatile} A reference to the volatile for chaining.
         */
        run() {
            for (let object of this.objects) {
                let x = object.x;
                let y = object.y;

                switch (this.opts.direction) {
                    case 'top':
                        y -= this.opts.distance;
                        break
                    case 'right':
                        x += this.opts.distance;
                        break
                    case 'bottom':
                        y += this.opts.distance;
                        break
                    case 'left':
                        x -= this.opts.distance;
                        break
                }

                TweenLite.to(object, this.opts.duration, {
                    x,
                    y,
                    alpha: 0,
                    ease: this.opts.ease,
                    overwrite: 'all',
                    onStart: () => {
                        if (this.opts.onStart) {
                            this.opts.onStart.call(object, object);
                        }
                    },
                    onComplete: () => {
                        if (this.opts.onComplete) {
                            this.opts.onComplete.call(object, object);
                        }

                        if (this.opts.destroyOnComplete) {
                            object.destroy({ children: true });
                        }
                    }
                });
            }

            return this
        }
    }

    /* globals */

    /**
     * Class that represents a PixiJS List.
     *
     * @example
     * const elephant1 = PIXI.Sprite.fromImage('./assets/elephant-1.jpg')
     * const elephant2 = PIXI.Sprite.fromImage('./assets/elephant-2.jpg')
     *
     * // Create the list
     * const list = new List([elephant1, elephant2])
     *
     * app.scene.addChild(list)
     *
     * @class
     * @extends PIXI.Container
     * @see {@link http://pixijs.download/dev/docs/PIXI.Container.html|PixiJS Container}
     * @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/list.html|DocTest}
     */
    class List extends PIXI.Container {
        /**
         * Creates an instance of a Flippable.
         *
         * @constructor
         * @param {PIXI.DisplayObject[]} items - An array of PIXI.DisplayObjects.
         * @param {object} [opts] - An options object which can contain the following properties.
         * @param {number} [opts.width] - The width of the list. If the items are larger than this width, the overflow
         *     will be hidden.
         * @param {number} [opts.height] - The height of the list. If the items are larger than this height, the overflow
         *     will be hidden.
         * @param {number} [opts.padding=10] - The inner spacing (distance from one item to the previous/next item).
         * @param {number} [opts.margin=10] - The outer spacing (distance from one item to the border).
         * @param {string} [opts.orientation=vertical] - The orientation of the button group. Can be horizontal or vertical.
         * @param {string} [opts.align=left] - The horizontal position of the items. Possible values are
         *     left, center and right.
         * @param {string} [opts.verticalAlign=middle] - The vertical position of the items. Possible values are
         *     top, middle and bottom.
         * @param {PIXI.Application} [opts.app] - The PixiJS Application. Must be set if you want to use the mousewheel to
         *     scroll your list.
         */
        constructor(items = [], opts = {}) {
            super();

            this.opts = Object.assign(
                {},
                {
                    padding: 10,
                    margin: 10,
                    orientation: 'vertical',
                    align: 'left',
                    verticalAlign: 'middle',
                    width: null,
                    height: null,
                    app: null
                },
                opts
            );

            this.__items = items;
            this.__dragging = false;

            // setup
            //--------------------
            this.setup();
        }

        /**
         * Creates children and instantiates everything.
         *
         * @private
         * @return {List} A reference to the list for chaining.
         */
        setup() {
            // inner container
            //--------------------
            const container = new PIXI.Container();
            this.addChild(container);
            this.container = container;

            // mask
            //--------------------
            const mask = new PIXI.Graphics();
            this.addChild(mask);
            this.__mask = mask;

            // add items
            //--------------------
            for (let item of this.__items) {
                container.addChild(item);
            }

            // interaction
            //--------------------
            this.interactive = this.opts.width || this.opts.height;
            this.on('pointerdown', this.onStart.bind(this));
            this.on('pointermove', this.onMove.bind(this));
            this.on('pointerup', this.onEnd.bind(this));
            this.on('pointercancel', this.onEnd.bind(this));
            this.on('pointerout', this.onEnd.bind(this));
            this.on('pointerupoutside', this.onEnd.bind(this));
            this.on('scroll', this.onScroll.bind(this));

            // mousewheel
            //--------------------
            if (this.opts.app) {
                const app = this.opts.app;
                app.view.addEventListener('mousewheel', event => {
                    const bounds = this.mask
                        ? this.mask.getBounds()
                        : this.getBounds();
                    const x = event.clientX - app.view.getBoundingClientRect().left;
                    const y = event.clientY - app.view.getBoundingClientRect().top;
                    if (bounds.contains(x, y)) {
                        event.preventDefault();
                        this.emit('scroll', event);
                    }
                });
            }

            this.layout();

            return this
        }

        /**
         * Replaces the existing items and relayouts the list.
         *
         * @param {PIXI.DisplayObject[]} items - An array of PIXI.DisplayObjects.
         * @return {List} A reference to the list for chaining.
         */
        setItems(items) {
            this.container.removeChildren();
            this.__items = items;
            for (let item of this.__items) {
                this.container.addChild(item);
            }
            this.layout();
        }

        /**
         * Should be called to refresh the layout of the list (the width or the height).
         *
         * @return {List} A reference to the list for chaining.
         */
        layout() {
            const margin = this.opts.margin;

            let x = margin;
            let y = margin;

            for (let item of this.__items) {
                item.x = x;
                item.y = y;

                if (this.opts.orientation === 'vertical') {
                    y += item.height + this.opts.padding;
                } else {
                    x += item.width + this.opts.padding;
                }
            }

            // vertical
            //--------------------
            if (this.opts.orientation === 'vertical') {
                switch (this.opts.align) {
                    case 'center':
                        this.__items.forEach(
                            it => (it.x = margin + this.width / 2 - it.width / 2)
                        );
                        break
                    case 'right':
                        this.__items.forEach(
                            it => (it.x = margin + this.width - it.width)
                        );
                        break
                    default:
                        this.__items.forEach(it => (it.x = margin));
                        break
                }

                if (this.opts.height) {
                    const mask = this.__mask;
                    mask.clear();
                    mask.beginFill(0x000);
                    mask.drawRect(0, 0, this.width + 2 * margin, this.opts.height);
                    this.mask = mask;

                    this.interactive = this.innerHeight > this.opts.height;
                }
            }

            // horizontal
            //--------------------
            if (this.opts.orientation === 'horizontal') {
                switch (this.opts.verticalAlign) {
                    case 'top':
                        this.__items.forEach(it => (it.y = margin));
                        break
                    case 'bottom':
                        this.__items.forEach(
                            it => (it.y = margin + this.height - it.height)
                        );
                        break
                    default:
                        this.__items.forEach(
                            it => (it.y = margin + this.height / 2 - it.height / 2)
                        );
                        break
                }

                if (this.opts.width) {
                    const mask = this.__mask;
                    mask.clear();
                    mask.beginFill(0x000);
                    mask.drawRect(0, 0, this.opts.width, this.height + 2 * margin);
                    this.mask = mask;

                    this.interactive = this.innerWidth > this.opts.width;
                }
            }

            return this
        }

        /**
         *
         */
        get innerWidth() {
            let size = 0;

            this.__items.forEach(it => (size += it.width));
            size += this.opts.padding * (this.__items.length - 1);
            size += 2 * this.opts.margin;

            return size
        }

        /**
         *
         */
        get innerHeight() {
            let size = 0;

            this.__items.forEach(it => (size += it.height));
            size += this.opts.padding * (this.__items.length - 1);
            size += 2 * this.opts.margin;

            return size
        }

        /**
         * Resizes the list.
         *
         * @param {number} widthOrHeight - The new width (if orientation is horizontal) or height (if orientation is vertical) of the list.
         */
        resize(widthOrHeight) {
            if (this.opts.orientation === 'horizontal') {
                this.opts.width = widthOrHeight;
            } else {
                this.opts.height = widthOrHeight;
            }

            this.layout();
        }

        /**
         *
         * @private
         * @param {*} event
         */
        onStart(event) {
            this.__dragging = true;

            this.capture(event);

            this.__delta = {
                x: this.container.position.x - event.data.global.x,
                y: this.container.position.y - event.data.global.y
            };

            TweenLite.killTweensOf(this.container.position, { x: true, y: true });
            if (typeof ThrowPropsPlugin != 'undefined') {
                ThrowPropsPlugin.track(this.container.position, 'x,y');
            }
        }

        /**
         *
         * @private
         * @param {*} event
         */
        onMove(event) {
            if (this.__dragging) {
                this.capture(event);

                if (this.opts.orientation === 'horizontal') {
                    this.container.position.x = event.data.global.x + this.__delta.x;
                } else {
                    this.container.position.y = event.data.global.y + this.__delta.y;
                }
            }
        }

        /**
         *
         * @private
         * @param {*} event
         */
        onEnd(event) {
            if (this.__dragging) {
                this.__dragging = false;

                this.capture(event);

                const throwProps = {};

                if (this.opts.orientation === 'horizontal') {
                    let min = this.opts.width - this.innerWidth;
                    min = min > 0 ? 0 : min;
                    throwProps.x = {
                        velocity: 'auto',
                        min,
                        max: 0
                    };
                } else {
                    let min = this.opts.height - this.innerHeight;
                    min = min > 0 ? 0 : min;
                    throwProps.y = {
                        velocity: 'auto',
                        min,
                        max: 0
                    };
                }

                if (typeof ThrowPropsPlugin != 'undefined') {
                    ThrowPropsPlugin.to(
                        this.container.position,
                        {
                            throwProps,
                            ease: Strong.easeOut,
                            onComplete: () =>
                                ThrowPropsPlugin.untrack(this.container.position)
                        },
                        0.8,
                        0.4
                    );
                }
            }
        }

        /**
         *
         * @private
         * @param {*} event
         */
        onScroll(event) {
            this.capture(event);

            if (this.opts.orientation === 'horizontal') {
                this.container.position.x -= event.deltaX;
                if (this.container.position.x > 0) {
                    this.container.position.x = 0;
                } else if (
                    this.container.position.x + this.innerWidth <
                    this.opts.width
                ) {
                    this.container.position.x = this.opts.width - this.innerWidth;
                }
            } else {
                this.container.position.y -= event.deltaY;
                if (this.container.position.y > 0) {
                    this.container.position.y = 0;
                } else if (
                    this.container.position.y + this.innerHeight <
                    this.opts.height
                ) {
                    this.container.position.y = this.opts.height - this.innerHeight;
                }
            }
        }

        /**
         * Captures an event to inform InteractionMapper about processed events.
         *
         * @param {event|PIXI.InteractionEvent} event - The PIXI event to capture.
         */
        capture(event) {
            const originalEvent =
                event.data && event.data.originalEvent
                    ? event.data.originalEvent
                    : event;
            Events$1.capturedBy(originalEvent, this);
        }
    }

    /* Needed to ensure that rollup.js includes class definitions and the classes
    are visible inside doctests.
    */
    window.PIXIApp = PIXIApp;
    window.BlurFilter = BlurFilter;
    window.FlipEffect = FlipEffect;
    window.Flippable = Flippable;
    window.DeepZoomInfo = DeepZoomInfo;
    window.DeepZoomImage = DeepZoomImage;
    window.Popover = Popover;
    window.ScatterContainer = ScatterContainer;
    window.DisplayObjectScatter = DisplayObjectScatter;
    window.Command = Command;
    window.RecorderTools = RecorderTools;
    window.Timeline = Timeline;
    window.AppTest = AppTest;
    window.Theme = Theme;
    window.Button = Button;
    window.ButtonGroup = ButtonGroup;
    window.Scrollview = Scrollview;
    window.Slider = Slider;
    window.Stylus = Stylus;
    window.Switch = Switch;
    window.Popup = Popup;
    window.PopupMenu = PopupMenu;
    window.Modal = Modal;
    window.Volatile = Volatile;
    window.Message = Message;
    window.Tooltip = Tooltip;
    window.Badge = Badge;
    window.Progress = Progress;
    window.List = List;
    window.LabeledGraphics = LabeledGraphics;
    window.FontInfo = FontInfo;

}());