Source: clock.js

import WorldClockError from "./error.js";
import { Datatype } from "./utils.js";
import Menu from "./menu.js";
import WidgetFace from "./widget-face.js";
import TimezoneGroup from "./timezone-group.js";
import Config from "./config.js";
import TimezoneRegion from "./timezone-region.js";
import TimezoneCity from "./timezone-city.js";
import TimezoneLocalStorage from "./storage.js";
import TimezoneObject from "./timezone-object.js";
import TemplateBuilder from "./template-builder.js";
import { CSS } from "./css.js";

/**
 * World Clock API component.
 */
class Clock {
    /**
     * @param {Object} params - Object containing all configurable parameters.
     * @param {String} params.container - container element ID.
     * @param {String} [params.theme] - widget color theme. Defaul value = "light". Available values = ["light", "dark"].
     * @param {Boolean} [params.showSeconds=true] - show seconds in the clocks? Default value = true.
     * @param {Boolean} [params.showDate=true] - show date in the widget? Default value = true.
     */
    constructor(params) {
        this._params = params;
        this._frameId = null;
        this._startTimestamp = null;
        this._parseParams(params);
        this._setContainer(params);
        this._init();
    }

    /**
     * Get all currently available timezone parts.
     * Returns a product of *this.getAllAvailableTimezones()*, 
     * because that is saved to the Clock at the initialization time.
     * @returns {Object} all currently available timezone parts.
     * @public
     * @readonly Property is read-only and cannot be modified.
     */
    get timezoneGroups() {
        return this._timezoneGroups;
    }

    /**
     * Initialize widget.
     * @throws {WorldClockError} if initialization has failed.
     * @private
     */
    async _init() {
        if (!this._checkMomentDependancies()) {
            await this._importMomentDependancies();
        }
        try {
            this._importOtherDependancies();
            this._timezoneGroups = this.getAllAvailableTimezones();
            this._localStorage = new TimezoneLocalStorage();
            const timezone = this._findReconstructedTimezoneObject(this._localStorage.getTimezone());
            if (timezone) this.selectCurrentTimezone(timezone);
            this._createWidget();
        } catch (err) {
            console.error(err);
            throw new WorldClockError(`Failed to setup clock as of error - ${err}.`);
        }
    }

    /**
     * Method to parse all given by user parameters.
     * @param {Object} params - Object containing all configurable parameters.
     * @param {String} params.container - container element ID.
     * @param {String} [params.theme] - widget color theme. Defaul value = "light". Available values = ["light", "dark"].
     * @param {Boolean} [params.showSeconds=true] - show seconds in the clocks? Default value = true.
     * @param {Boolean} [params.showDate=true] - show date in the widget? Default value = true.
     * @throws {WorldClockError} if required parameter was wrong (not defined, not a String).
     * @private
     */
    _parseParams(params = {}) {
        this.setTimeSecondsShow(params.showSeconds);
        this.setDateShow(params.showDate);
        this.setTheme(params.theme);
    }

    /**
     * Widget checks if website already has necessary dependancies - **moment.js**, **moment-timezone.js**.
     * @returns {Boolean} are dependancies fulfilled.
     * @private
     */
    _checkMomentDependancies() {
        return window.moment && window.moment.tz;
    }

    /**
     * Dinamically import CSS of the widget.
     * @private
     */
    _importOtherDependancies() {
        if (!document.getElementById(Config.WidgetStyleElmId)) {
            const style = document.createElement("style");
            style.innerHTML = CSS;
            style.id = Config.WidgetStyleElmId;
            document.head.appendChild(style);
        }
    }

    /**
     * Dinamically import dependancies - **moment.js**, **moment-timezone.js**, if they were not found in the website.
     * This method is called after *this._checkMomentDependancies()*.
     * @returns {Promise} fulfilled or rejected Promise, which is being waited in the initialization of the widget.
     * @private
     */
    _importMomentDependancies() {
        return new Promise((resolve, reject) => {
            try {
                // Dynamically import JS packages. 
                const moment = document.createElement("script");
                const momentTimezone = document.createElement("script");
                moment.src = Config.MomentPath;
                momentTimezone.src = Config.MomentTimezonePath;
                moment.async = false;
                momentTimezone.async = false;
                document.body.appendChild(moment);
                moment.addEventListener("load", _ => document.body.appendChild(momentTimezone));
                momentTimezone.addEventListener("load", _ => resolve());
            } catch (err) {
                reject(err);
            }
        });
    }

    /**
     * Method generates HTML for the widget and then calls *this._showWidget*,
     * which updates time and date text.
     * @private
     */
    _createWidget() {
        this.wrapper = TemplateBuilder.buildWidgetWrapper(this.theme);
        this.container.appendChild(this.wrapper);
        this.wrapper.appendChild(TemplateBuilder.buildWidgetFace(this.showDate));
        this.wrapper.appendChild(TemplateBuilder.buildMenu());

        this._widgetFace = new WidgetFace(this);
        this._menu = new Menu(this);
        this._showWidget();
    }

    /**
     * Display current time and date in the widget.
     * @private
     */
    _showWidget() {
        this._updateClock();
    }

    /**
     * Method to parse all given by user parameters.
     * @param {Object} params - Object containing all configurable parameters.
     * @param {String} params.container - container element ID.
     * @param {String} [params.theme] - widget color theme. Defaul value = "light". Available values = ["light", "dark"].
     * @param {Boolean} [params.showSeconds=true] - show seconds in the clocks? Default value = true.
     * @param {Boolean} [params.showDate=true] - show date in the widget? Default value = true.
     * @throws {WorldClockError} if required parameter - 'container' was wrong (not defined, not a String or no element exist in DOM).
     * @private
     */
    _setContainer(params = {}) {
        if (!params.container) {
            throw new WorldClockError("'container' parameter was not specified.");
        } else if (typeof (params.container) !== Datatype.STRING) {
            throw new WorldClockError("Please, define 'container' parameter properly.");
        }
        const cont = document.getElementById(params.container);
        if (!cont) {
            throw new WorldClockError(`Element with specified - ${params.container} element ID, does not exist.`);
        }
        this.container = cont;
    }

    /**
     * Find TimezoneObject within the available timezones given by *type* and *name*.
     * @param {String} type one of the types from ["group", "region", "city"]. 
     * @param {String} name name of the part that is being searched. For example - "Vilnius". 
     * @returns {null|TimezoneObject} null - if part was not found. TimezoneObject - otherwise.
     * @public
     */
    findTimezonePart(type, name) {
        if (!Object.values(Config.TimezoneListObjectTypes).includes(type)) {
            throw new WorldClockError(`Undefined timezone list element type - ${type}.`);
        }
        let part = null;
        switch (type) {
            case Config.TimezoneListObjectTypes.Group:
                part = this.findGroup(name);
                break;
            case Config.TimezoneListObjectTypes.Region:
                part = this.findRegion(name);
                break;
            case Config.TimezoneListObjectTypes.City:
                part = this.findCity(name);
                break;
        }

        return part;
    }

    /**
     * Search for a TimezoneGroup in all available timezones.
     * @param {String} name TimezoneGroup name, which is being searched. 
     * @returns {null|TimezoneGroup} null - if group was not found, TimezoneGroup - otherwise.
     * @public
     */
    findGroup(name) {
        const group = Object.values(this.timezoneGroups).find(group => group.name === name);
        return group === undefined ? null : group;
    }

    /**
     * Search for a TimezoneRegion in all available timezones.
     * @param {String} name TimezoneRegion name, which is being searched.
     * @returns {null|TimezoneRegion} null - if region was not found, TimezoneRegion - otherwise.
     * @public
     */
    findRegion(name) {
        let region = null;
        const groups = Object.values(this.timezoneGroups);
        for (let i = 0; i < groups.length; i++) {
            const group = groups[i];
            region = Object.values(group.regions).find(region => region.name === name);
            if (region instanceof TimezoneRegion) return region;
        }
        return region === undefined ? null : region;
    }

    /**
     * Search for a TimezoneCity in all available timezones.
     * @param {String} name TimezoneCity name, which is being searched. 
     * @returns {null|TimezoneCity} null - if city was not found, TimezoneCity - otherwise.
     * @public
     */
    findCity(name) {
        let city = null;
        const groups = Object.values(this.timezoneGroups);
        for (let i = 0; i < groups.length; i++) {
            const group = groups[i];
            const regions = Object.values(group.regions);
            for (let j = 0; j < regions.length; j++) {
                const region = regions[j];
                city = Object.values(region.cities).find(city => city.name === name);
                if (city instanceof TimezoneCity) return city;
            }
        }
        return city === undefined ? null : city;
    }

    /**
     * In order to set current timezone one of the timezone parts must be passed.
     * We automatically detect type of timezone part, then detect if input is recognized.
     * If input is recognized current selected timezone is updated.
     * Otherwise we throw WorldClockError.
     * @param {TimezoneObject} part TimezoneObject part, which should be selected.
     * @public
     */
    selectCurrentTimezone(part) {
        if (part instanceof TimezoneObject) {
            if (this._findReconstructedTimezoneObject(part)) {
                this._selectedTimezone = part;
                this._localStorage.saveTimezone(part);
            } else {
                throw new WorldClockError(`Undefined timezone element - ${part}.`);
            }
        } else {
            throw new WorldClockError(`Undefined timezone element type - ${part}.`);
        }
    }

    /**
     * Get all available timezone groups from moment-timezone.
     * In moment-timezone group, region, city is marked in this format: "Group/Region/City".
     * Method splits all strings by "/" and then collects all parts at index 0.
     * So we get a list of timezone group names.
     * @returns {Array} array of timezone group names.
     * @public
     */
    getTimezoneGroups() {
        const groups = new Set();
        moment.tz.names().forEach(timezone => { groups.add(timezone.split('/')[0]) });
        return groups;
    }

    /**
     * Show all available timezones, which are hierarchically related.
     * At the top of hierarchy lies TimezoneGroup Objects.
     * Returns this data structure: 
     * <pre><code>
     * {
     *      'timezone_group_name': TimezoneGroup()
     *      ...
     * }
     * </code></pre>
     * TimezoneRegion Objects are included into TimezoneGroup.
     * TimezoneCity Objects are included into TimezoneRegion.
     * @returns {Object} hierarchical object.
     * @public
     */
    getAllAvailableTimezones() {
        const tzEntries = moment.tz.names();

        // Gather timezone groups.
        const timezoneGroupNames = this.getTimezoneGroups();
        const timezoneGroups = {};
        timezoneGroupNames.forEach(timezoneGroupName => {
            timezoneGroups[timezoneGroupName] = new TimezoneGroup(timezoneGroupName);
        });

        // Fill in timezone regions and cities.
        tzEntries.forEach(timezoneString => {
            const parts = timezoneString.split('/');
            const group = parts[0];
            const region = parts[1];
            const city = parts[2];

            if (group && !region && !city) {
                timezoneGroups[group].selectable = true;
            }

            if (group && region && !city) {
                timezoneGroups[group].addRegion(region, true);
            }
            if (group && region && city) {
                timezoneGroups[group].addRegionCity(region, city, false, true);
            }
        });

        return timezoneGroups;
    }

    /**
     * Every second updates widget text (time, date, timezone).
     * @param {DOMHighResTimeStamp} timestamp used to track render time from requestAnimationFrame.
     * @private
     */
    _render(timestamp) {
        if (!this._startTimestamp) this._startTimestamp = timestamp;
        const timePassed = timestamp - this._startTimestamp;
        if (timePassed >= 1000) {
            this._startTimestamp = timestamp;
            this._updateClock();
        } else {
            this._frameId = requestAnimationFrame(this._render.bind(this));
        }
    }

    /**
     * Updates widget text (time, date, timezone) with the current widget configuration.
     * @private
     */
    _updateClock() {
        this._frameId = requestAnimationFrame(this._render.bind(this));
        if (this._selectedTimezone) {
            const timeFrame = moment.tz(this._selectedTimezone.timezone);
            this._updateWidgetDateTime(timeFrame);
            this._widgetFace.setTimezone(this._selectedTimezone.timezoneName);
        } else {
            const timeFrame = moment();
            this._updateWidgetDateTime(timeFrame);
            this._widgetFace.setTimezone("Local");
        }
    }

    /**
     * Invokes widget to update text (time, date) from a specified moment timeframe. 
     * @param {moment} timeFrame equivalent to *moment()* timeframe.
     * @private
     */
    _updateWidgetDateTime(timeFrame) {
        const time = this.showSeconds ? timeFrame.format(Config.DefaultTimeFormat) : timeFrame.format(Config.NoSecondsTimeFormat);
        const date = timeFrame.format(Config.DefaultDateFormat);
        this._widgetFace.setTime(time);
        this._widgetFace.setDate(date);
    }

    /**
     * Allows to verify if reconstructed TimezoneObject from LocalStorage (or user input) is valid or not.
     * @param {TimezoneObject} reconstructedTimezoneObject TimezoneObject which is constructed from storage or user input.
     * @returns {null|TimezoneObject} null - if TimezoneObject was not recognized. TimezoneObject - otherwise.
     * @private
     */
    _findReconstructedTimezoneObject(reconstructedTimezoneObject) {
        const groups = this.timezoneGroups;
        if (reconstructedTimezoneObject instanceof TimezoneObject) {
            if (reconstructedTimezoneObject instanceof TimezoneGroup) {
                if (groups[reconstructedTimezoneObject.name] !== undefined) {
                    return groups[reconstructedTimezoneObject.name];
                }
            } else if (reconstructedTimezoneObject instanceof TimezoneRegion) {
                for (let group of Object.values(groups)) {
                    for (let region of Object.values(group.regions)) {
                        if (region.name === reconstructedTimezoneObject.name) {
                            return region;
                        }
                    }
                }
            } else {
                for (let group of Object.values(groups)) {
                    for (let region of Object.values(group.regions)) {
                        for (let city of Object.values(region.cities)) {
                            if (city.name === reconstructedTimezoneObject.name) {
                                return city;
                            }
                        }
                    }
                }
            }
        }
        return null; // Not found.
    }

    /**
     * Validate and set widget theme.
     * If given value is not valid, light theme will be selected.
     * @param {String} themeValue controls which widget theme should be selected.
     * @public
     */
    setTheme(themeValue) {
        if (themeValue !== undefined && (typeof (themeValue) === Datatype.STRING) && [Config.Themes.Dark, Config.Themes.Light].includes(themeValue)) {
            this.theme = themeValue;
            if (this.container) {
                this.wrapper.classList.remove(Config.ThemeToCSS[Config.Themes.Dark], Config.ThemeToCSS[Config.Themes.Light]);
                this.wrapper.classList.add(Config.ThemeToCSS[themeValue]);
            }
        } else {
            this.theme = Config.Themes.Light;
        }
    }

    /**
     * Validate and set flag, which controls if widget should show date field.
     * Default value = true. 
     * @param {Boolean} value controls if widget should show date field.
     * @public
     */
    setDateShow(value) {
        if (value !== undefined && typeof (value) === Datatype.BOOLEAN) {
            this.showDate = value;
            if (this.container) {
                const dateField = document.getElementById(Config.DateElmId);
                if (value) {
                    dateField.classList.remove(Config.CSS.Hidden);
                } else {
                    dateField.classList.add(Config.CSS.Hidden);
                }
            }
        } else {
            this.showDate = true;
        }
    }

    /**
     * Validate and set flag, which controls if widget should show seconds.
     * Default = true.
     * @param {Boolean} value controls if widget should show seconds.
     */
    setTimeSecondsShow(value) {
        if (value !== undefined && typeof (value) === Datatype.BOOLEAN) {
            this.showSeconds = value;
        } else {
            this.showSeconds = true;
        }
    }

    /**
     * User Convenience theme selection property.
     * @public
     */
    static Theme = {
        Dark: Config.Themes.Dark,
        Light: Config.Themes.Light
    }
}

export default Clock;