interactive-panorama.js

import { LAYOUTS, POSITIONS, VISUALIZER_EVENTS } from './constants';
import { EventBus } from './event-bus';
import { validateLayoutSettings, validateDataType } from './utils';

/**
 * @ignore
 */
const get360viewEndpoint = (token, vendorCode, interiorName, layingPatternCode) => {
    return new Promise((resolve, reject) => {
        const url = new URL(`https://pim-client.wizart.ai/api/v2.1/panorama/product/${vendorCode}`);
        url.searchParams.set('interior_name', interiorName);
        if (layingPatternCode) {
            url.searchParams.set('laying_pattern_code', layingPatternCode);
        }

        fetch(
            url.toString(),
            {
                headers: {
                    Accept: 'application/json',
                    Authorization: token,
                },
            }
        )
            .then((r) => r.json())
            .then((response) => {
                if (response?.data?.attributes?.config) {
                    resolve(
                        `https://vr360view.wizart.ai/?config=${response.data.attributes.config}&source=depl-kit`
                    );
                } else {
                    reject(new Error('No panorama config found for the given vendorCode / interiorName'));
                }
            })
            .catch(reject);
    });
};

/**
 * @ignore
 */
const CLOSE_ICON_SVG = `<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
  <g clip-path="url(#clip0_704_7177)">
  <path d="M6.28216 14.4432C6.47625 14.4432 6.65917 14.3678 6.796 14.2309L9.99913 11.0278L13.2023 14.2309C13.3391 14.3678 13.522 14.4432 13.7161 14.4432C13.9102 14.4432 14.0931 14.3678 14.2299 14.2309C14.3682 14.0927 14.4436 13.9112 14.4436 13.7157C14.4436 13.5216 14.3682 13.3387 14.2299 13.2018L11.0268 10.0001L14.2299 6.79698C14.3668 6.66014 14.4436 6.47723 14.4436 6.28314C14.4436 6.08905 14.3682 5.90614 14.2313 5.7693C14.0931 5.63106 13.9102 5.55566 13.7161 5.55566C13.522 5.55566 13.3391 5.63106 13.2023 5.7679L9.99913 8.97243L6.796 5.7679C6.65777 5.63106 6.47625 5.55566 6.28216 5.55566C6.08808 5.55566 5.90516 5.63106 5.76832 5.7679C5.63009 5.90614 5.55469 6.08905 5.55469 6.28314C5.55469 6.47723 5.63009 6.65875 5.76693 6.79698L8.97005 10.0001L5.76693 13.2032C5.63009 13.3401 5.55469 13.523 5.55469 13.7171C5.55469 13.9112 5.63009 14.0941 5.76832 14.2309C5.90516 14.3678 6.08668 14.4432 6.28216 14.4432Z" fill="#242236"/>
  </g>
  <defs>
  <clipPath id="clip0_704_7177">
  <rect width="8.88889" height="8.88889" fill="white" transform="translate(5.55469 5.55566)"/>
  </clipPath>
  </defs>
</svg>`;

/**
 * @typedef {Object} PanoramaOptions
 * @property {string}              token                     - Authentication token.
 * @property {LayoutSettings}      layoutSettings            - Settings for the iframe layout.
 * @property {PanoramaSceneData}   sceneData                 - Scene data (required).
 * @property {string}              [onCloseCallback]         - Stringified callback fired on close.
 */

/**
 * @typedef {'no_offset'|'1_2_offset'|'1_3_offset'|'random_offset'|'basket_weave'|'chevron'|'herringbone'|'double_herringbone'|'1_2_offset_horizontal'|'no_pattern'} LayingPatternCode
 */

/**
 * @typedef {Object} PanoramaSceneData
 * @property {string}              vendorCode                - Product vendor code (required).
 * @property {string}              interiorName              - Room / interior name (required).
 * @property {LayingPatternCode}   [layingPatternCode]       - Tile laying pattern. Possible values (required):
 *   - `no_offset`             – No offset
 *   - `1_2_offset`            – 1/2 offset
 *   - `1_3_offset`            – 1/3 offset
 *   - `random_offset`         – Random offset
 *   - `basket_weave`          – Basket weave
 *   - `chevron`               – Chevron
 *   - `herringbone`           – Herringbone
 *   - `double_herringbone`    – Double herringbone
 *   - `1_2_offset_horizontal` – 1/2 offset horizontal
 *   - `no_pattern`            – No pattern
 */

/**
 * Class representing a 360 Panorama viewer.
 *
 * @class
 * @memberof WizartDeploymentKit
 *
 * @example
 * const panorama = new WizartDeploymentKit.Panorama({
 *     token: 'YOUR_TOKEN',
 *     layoutSettings: {
 *         targetElement: document.getElementById('panorama-container'),
 *         layout: WizartDeploymentKit.LAYOUTS.FILL_PARENT,
 *     },
 *     sceneData: {
 *         vendorCode: 'CARPET_01',
 *         interiorName: 'living-room',
 *         layingPatternCode: 'herringbone',
 *     },
 * });
 *
 * panorama.show();
 */
class Panorama {
    /**
     * @constructor
     * @param {PanoramaOptions} options
     */
    constructor(options = {}) {
        const {
            token,
            layoutSettings = {},
            sceneData = {},
            onCloseCallback = null,
        } = options;

        if (!token || token === 'token') {
            console.error('Panorama: token is required.');
            return;
        }

        if (!sceneData.vendorCode) {
            console.error('Panorama: sceneData.vendorCode is required.');
            return;
        }

        if (!sceneData.interiorName) {
            console.error('Panorama: sceneData.interiorName is required.');
            return;
        }

        this.token = token;
        this.onCloseCallback = onCloseCallback;

        this.iframe = null;

        validateLayoutSettings(layoutSettings);
        validateDataType(sceneData);

        this.layoutSettings = layoutSettings;

        this.sceneData = sceneData;
        this.layout = this.layoutSettings.layout || LAYOUTS.FULL_SCREEN;
        this.position = this.layoutSettings.position || POSITIONS.REPLACE;

        this.isLoaded = false;

        this.isVisible = false;

        this._loadPromiseQueue = [];
        this._localSubscriptionIds = [];
    }

    /**
     * Closes and removes the iframe, fires onCloseCallback.
     * @ignore
     */
    onCloseIframe() {
        if (!this.iframe) return;

        if (this.layout === LAYOUTS.FULL_SCREEN) {
            document.getElementsByTagName('html')[0].style.overflow = 'auto';
        }

        if (this._wrapper) {
            this._wrapper.remove();
            this._wrapper = null;
        } else {
            this.iframe.remove();
        }

        this.iframe = null;
        this.isLoaded = false;
        this.isVisible = false;

        if (this.onCloseCallback) {
            try {
                const callback = eval(this.onCloseCallback);
                if (typeof callback === 'function') {
                    callback();
                } else {
                    console.error('onCloseCallback is not a function');
                }
            } catch (error) {
                console.error(error);
            }
        }
    }

    /**
     * Creates a wrapper div with position:relative that contains the iframe
     * and a close icon pinned to its top-right corner.
     * @ignore
     */
    _mountInWrapper(targetElement) {
        this._wrapper = document.createElement('div');
        this._wrapper.style.cssText = 'position: relative; width: 100%; height: 100%;';

        this.iframe = document.createElement('iframe');
        this.iframe.name = 'wizartFittingRoom';
        this.iframe.allowFullscreen = true;
        this.iframe.allow = 'web-share;clipboard-write';
        this.iframe.classList.add('wizart-shared');
        this.iframe.classList.add(this.layout);
        this.iframe.style.opacity = '0';
        this.iframe.style.pointerEvents = 'none';

        const closeIcon = document.createElement('div');
        closeIcon.innerHTML = CLOSE_ICON_SVG;
        const isFullScreen = this.layout === LAYOUTS.FULL_SCREEN;
        closeIcon.style.cssText = isFullScreen
            ? 'position: fixed; top: 12px; right: 12px; cursor: pointer;' +
            ' z-index: 2147483647; width: 40px; height: 40px;' +
            ' background: #fff; display: flex; justify-content: center;' +
            ' align-items: center; border-radius: 50%;' +
            ' opacity: 0; pointer-events: none;'
            : 'position: absolute; top: 12px; right: 12px; cursor: pointer;' +
            ' z-index: 2147483647; width: 40px; height: 40px;' +
            ' background: #fff; display: flex; justify-content: center;' +
            ' align-items: center; border-radius: 50%;' +
            ' opacity: 0; pointer-events: none;';

        closeIcon.onclick = () => this.onCloseIframe();

        this._closeIcon = closeIcon;
        this._wrapper.appendChild(this.iframe);
        this._wrapper.appendChild(closeIcon);

        switch (this.position) {
            case POSITIONS.BEFORE:
                targetElement.before(this._wrapper);
                break;
            case POSITIONS.AFTER:
                targetElement.after(this._wrapper);
                break;
            case POSITIONS.REPLACE:
            default:
                targetElement.replaceChildren(this._wrapper);
        }
    }

    /**
     * Loads the Panorama iframe.
     * @returns {Promise<Panorama>}
     * @ignore
     */
    loadIframe() {
        if (this.isLoaded) return Promise.resolve(this);

        return new Promise((resolve, reject) => {
            this._loadPromiseQueue.push({ resolve, reject });

            if (!this.iframe) {
                this._mountInWrapper(this.layoutSettings.targetElement);
            }

            get360viewEndpoint(
                this.token,
                this.sceneData.vendorCode,
                this.sceneData.interiorName,
                this.sceneData.layingPatternCode
            )
                .then((url) => {
                    if (url !== this.iframe.src) {
                        this.iframe.src = url;
                    }

                    this.iframe.onload = () => {
                        this.isLoaded = true;

                        this._loadPromiseQueue.forEach(({ resolve }) => resolve(this));
                        this._loadPromiseQueue = [];
                    };

                    this.iframe.onerror = () => {
                        reject(new Error('Panorama: failed to load iframe'));
                    };
                })
                .catch(reject);
        });
    }

    /**
     * Shows the Panorama iframe, loading it first if needed.
     * Reveals the close icon pinned to the top-right corner of the iframe.
     * @returns {Promise<Panorama>}
     */
    async show() {
        await this.loadIframe();

        if (this.iframe) {
            this.iframe.style.opacity = '1';
            this.iframe.style.pointerEvents = 'auto';
            if (this.layout === LAYOUTS.FULL_SCREEN) {
                document.getElementsByTagName('html')[0].style.overflow = 'hidden';
            }
            this.isVisible = true;

            if (this._closeIcon) {
                this._closeIcon.style.opacity = '1';
                this._closeIcon.style.pointerEvents = 'auto';
            }
        }

        return this;
    }

    /**
     * Destroys the Panorama iframe instance and cleans up subscriptions.
     * @ignore
     */
    destroy() {
        this.onCloseIframe();

        setTimeout(() => {
            this._localSubscriptionIds.forEach((id) => EventBus.unsubscribe(id));
            this._localSubscriptionIds = [];
        }, 150);
    }
}

export { Panorama };