Reference Source

src/viewer/localization/LocaleService.js

import {Map} from "./../scene/utils/Map.js";

/**
 * @desc Localization service for a {@link Viewer}.
 *
 * * A LocaleService is a container of string translations ("messages") for various locales.
 * * A {@link Viewer} has its own default LocaleService at {@link Viewer#localeService}.
 * * We can replace that with our own LocaleService, or a custom subclass, via the Viewer's constructor.
 * * Viewer plugins that need localized translations will attempt to them for the currently active locale from the LocaleService.
 * * Whenever we switch the LocaleService to a different locale, plugins will automatically refresh their translations for that locale.
 *
 * ## Usage
 *
 * In the example below, we'll create a {@link Viewer} that uses an {@link XKTLoaderPlugin} to load a BIM model, and a
 * {@link NavCubePlugin}, which shows a camera navigation cube in the corner of the canvas.
 *
 * We'll also configure our Viewer with our own LocaleService instance, configured with English, Māori and French
 * translations for our NavCubePlugin.
 *
 * We could instead have just used the Viewer's default LocaleService, but this example demonstrates how we might
 * configure the Viewer our own custom LocaleService subclass.
 *
 * The translations fetched by our NavCubePlugin will be:
 *
 *  * "NavCube.front"
 *  * "NavCube.back"
 *  * "NavCube.top"
 *  * "NavCube.bottom"
 *  * "NavCube.left"
 *  * "NavCube.right"
 *
 * <br>
 * These are paths that resolve to our translations for the currently active locale, and are hard-coded within
 * the NavCubePlugin.
 *
 * For example, if  the LocaleService's locale is set to "fr", then the path "NavCube.back" will drill down
 * into ````messages->fr->NavCube->front```` and fetch "Arrière".
 *
 * If we didn't provide that particular translation in our LocaleService, or any translations for that locale,
 * then the NavCubePlugin will just fall back on its own default hard-coded translation, which in this case is "BACK".
 *
 * [[Run example](https://xeokit.github.io/xeokit-sdk/examples/index.html#localization_NavCubePlugin)]
 *
 * ````javascript
 * import {Viewer, LocaleService, NavCubePlugin, XKTLoaderPlugin} from "xeokit-sdk.es.js";
 *
 * const viewer = new Viewer({
 *
 *      canvasId: "myCanvas",
 *
 *      localeService: new LocaleService({
 *          messages: {
 *              "en": { // English
 *                  "NavCube": {
 *                      "front": "Front",
 *                      "back": "Back",
 *                      "top": "Top",
 *                      "bottom": "Bottom",
 *                      "left": "Left",
 *                      "right": "Right"
 *                  }
 *              },
 *              "mi": { // Māori
 *                  "NavCube": {
 *                      "front": "Mua",
 *                      "back": "Tuarā",
 *                      "top": "Runga",
 *                      "bottom": "Raro",
 *                      "left": "Mauī",
 *                      "right": "Tika"
 *                  }
 *              },
 *              "fr": { // Francais
 *                  "NavCube": {
 *                      "front": "Avant",
 *                      "back": "Arrière",
 *                      "top": "Supérieur",
 *                      "bottom": "Inférieur",
 *                      "left": "Gauche",
 *                      "right": "Droit"
 *                  }
 *              }
 *          },
 *          locale: "en"
 *      })
 *  });
 *
 * viewer.camera.eye = [-3.93, 2.85, 27.01];
 * viewer.camera.look = [4.40, 3.72, 8.89];
 * viewer.camera.up = [-0.01, 0.99, 0.03];
 *
 * const navCubePlugin = new NavCubePlugin(viewer, {
 *      canvasID: "myNavCubeCanvas"
 *  });
 *
 * const xktLoader = new XKTLoaderPlugin(viewer);
 *
 * const model = xktLoader.load({
 *     id: "myModel",
 *     src: "./models/xkt/Duplex.ifc.xkt",
 *     edges: true
 * });
 * ````
 *
 * We can dynamically switch our Viewer to a different locale at any time, which will update the text on the
 * faces of our NavCube:
 *
 * ````javascript
 * viewer.localeService.locale = "mi"; // Switch to Māori
 * ````
 *
 * We can load new translations at any time:
 *
 * ````javascript
 * viewer.localeService.loadMessages({
 *     "jp": { // Japanese
 *         "NavCube": {
 *             "front": "前部",
 *             "back": "裏",
 *             "top": "上",
 *             "bottom": "底",
 *             "left": "左",
 *             "right": "右"
 *         }
 *     }
 * });
 * ````
 *
 * And we can clear the translations if needed:
 *
 * ````javascript
 * viewer.localeService.clearMessages();
 * ````
 *
 * We can get an "updated" event from the LocaleService whenever we switch locales or load messages, which is useful
 * for triggering UI elements to refresh themselves with updated translations. Internally, our {@link NavCubePlugin}
 * subscribes to this event, fetching new strings for itself via {@link LocaleService#translate} each time the
 * event is fired.
 *
 * ````javascript
 * viewer.localeService.on("updated", () => {
 *     console.log( viewer.localeService.translate("NavCube.left") );
 * });
 * ````
 * @since 2.0
 */
class LocaleService {

    /**
     * Constructs a LocaleService.
     *
     * @param {*} [params={}]
     * @param {JSON} [params.messages]
     * @param {String} [params.locale]
     */
    constructor(params = {}) {

        this._eventSubIDMap = null;
        this._eventSubEvents = null;
        this._eventSubs = null;
        this._events = null;

        this._locale = "en";
        this._messages = {};
        this._locales = [];
        this._locale = "en";

        this.messages = params.messages;
        this.locale = params.locale;
    }

    /**
     * Replaces the current set of locale translations.
     *
     * * Fires an "updated" event when done.
     * * Automatically refreshes any plugins that depend on the translations.
     * * Does not change the current locale.
     *
     * ## Usage
     *
     * ````javascript
     * viewer.localeService.setMessages({
     *     messages: {
     *         "en": { // English
     *             "NavCube": {
     *                 "front": "Front",
     *                 "back": "Back",
     *                 "top": "Top",
     *                 "bottom": "Bottom",
     *                 "left": "Left",
     *                 "right": "Right"
     *             }
     *         },
     *         "mi": { // Māori
     *             "NavCube": {
     *                 "front": "Mua",
     *                 "back": "Tuarā",
     *                 "top": "Runga",
     *                 "bottom": "Raro",
     *                 "left": "Mauī",
     *                 "right": "Tika"
     *             }
     *         }
     *    }
     * });
     * ````
     *
     * @param {*} messages The new translations.
     */
    set messages(messages) {
        this._messages = messages || {};
        this._locales = Object.keys(this._messages);
        this.fire("updated", this);
    }

    /**
     * Loads a new set of locale translations, adding them to the existing translations.
     *
     * * Fires an "updated" event when done.
     * * Automatically refreshes any plugins that depend on the translations.
     * * Does not change the current locale.
     *
     * ## Usage
     *
     * ````javascript
     * viewer.localeService.loadMessages({
     *     "jp": { // Japanese
     *         "NavCube": {
     *             "front": "前部",
     *             "back": "裏",
     *             "top": "上",
     *             "bottom": "底",
     *             "left": "左",
     *             "right": "右"
     *         }
     *     }
     * });
     * ````
     *
     * @param {*} messages The new translations.
     */
    loadMessages(messages = {}) {
        for (let locale in messages) {
            this._messages[locale] = messages[locale];
        }
        this.messages = this._messages;
    }

    /**
     * Clears all locale translations.
     *
     * * Fires an "updated" event when done.
     * * Does not change the current locale.
     * * Automatically refreshes any plugins that depend on the translations, which will cause those
     * plugins to fall back on their internal hard-coded text values, since this method removes all
     * our translations.
     */
    clearMessages() {
        this.messages = {};
    }

    /**
     * Gets the list of available locales.
     *
     * These are derived from the currently configured set of translations.
     *
     * @returns {String[]} The list of available locales.
     */
    get locales() {
        return this._locales;
    }

    /**
     * Sets the current locale.
     *
     * * Fires an "updated" event when done.
     * * The given locale does not need to be in the list of available locales returned by {@link LocaleService#locales}, since
     * this method assumes that you may want to load the locales at a later point.
     * * Automatically refreshes any plugins that depend on the translations.
     * * We can then get translations for the locale, if translations have been loaded for it, via {@link LocaleService#translate} and {@link LocaleService#translatePlurals}.
     *
     * @param {String} locale The new current locale.
     */
    set locale(locale) {
        locale = locale || "de";
        if (this._locale === locale) {
            return;
        }
        this._locale = locale;
        this.fire("updated", locale);
    }

    /**
     * Gets the current locale.
     *
     * @returns {String} The current locale.
     */
    get locale() {
        return this._locale;
    }

    /**
     * Translates the given string according to the current locale.
     *
     * Returns null if no translation can be found.
     *
     * @param {String} msg String to translate.
     * @param {*} [args] Extra parameters.
     * @returns {String|null} Translated string if found, else null.
     */
    translate(msg, args) {
        const localeMessages = this._messages[this._locale];
        if (!localeMessages) {
            return null;
        }
        const localeMessage = resolvePath(msg, localeMessages);
        if (localeMessage) {
            if (args) {
                return vsprintf(localeMessage, args);
            }
            return localeMessage;
        }
        return null;
    }

    /**
     * Translates the given phrase according to the current locale.
     *
     * Returns null if no translation can be found.
     *
     * @param {String} msg Phrase to translate.
     * @param {Number} count The plural number.
     * @param {*} [args] Extra parameters.
     * @returns {String|null} Translated string if found, else null.
     */
    translatePlurals(msg, count, args) {
        const localeMessages = this._messages[this._locale];
        if (!localeMessages) {
            return null;
        }
        let localeMessage = resolvePath(msg, localeMessages);
        count = parseInt("" + count, 10);
        if (count === 0) {
            localeMessage = localeMessage.zero;
        } else {
            localeMessage = (count > 1) ? localeMessage.other : localeMessage.one;
        }
        if (!localeMessage) {
            return null;
        }
        localeMessage = vsprintf(localeMessage, [count]);
        if (args) {
            localeMessage = vsprintf(localeMessage, args);
        }
        return localeMessage;
    }

    /**
     * Fires an event on this LocaleService.
     *
     * Notifies existing subscribers to the event, optionally retains the event to give to
     * any subsequent notifications on the event as they are made.
     *
     * @param {String} event The event type name.
     * @param {Object} value The event parameters.
     * @param {Boolean} [forget=false] When true, does not retain for subsequent subscribers.
     */
    fire(event, value, forget) {
        if (!this._events) {
            this._events = {};
        }
        if (!this._eventSubs) {
            this._eventSubs = {};
        }
        if (forget !== true) {
            this._events[event] = value || true; // Save notification
        }
        const subs = this._eventSubs[event];
        if (subs) {
            for (const subId in subs) {
                if (subs.hasOwnProperty(subId)) {
                    const sub = subs[subId];
                    sub.callback(value);
                }
            }
        }
    }

    /**
     * Subscribes to an event on this LocaleService.
     *
     * @param {String} event The event
     * @param {Function} callback Callback fired on the event
     * @return {String} Handle to the subscription, which may be used to unsubscribe with {@link #off}.
     */
    on(event, callback) {
        if (!this._events) {
            this._events = {};
        }
        if (!this._eventSubIDMap) {
            this._eventSubIDMap = new Map(); // Subscription subId pool
        }
        if (!this._eventSubEvents) {
            this._eventSubEvents = {};
        }
        if (!this._eventSubs) {
            this._eventSubs = {};
        }
        let subs = this._eventSubs[event];
        if (!subs) {
            subs = {};
            this._eventSubs[event] = subs;
        }
        const subId = this._eventSubIDMap.addItem(); // Create unique subId
        subs[subId] = {
            callback: callback
        };
        this._eventSubEvents[subId] = event;
        const value = this._events[event];
        if (value !== undefined) {
            callback(value);
        }
        return subId;
    }

    /**
     * Cancels an event subscription that was previously made with {@link LocaleService#on}.
     *
     * @param {String} subId Subscription ID
     */
    off(subId) {
        if (subId === undefined || subId === null) {
            return;
        }
        if (!this._eventSubEvents) {
            return;
        }
        const event = this._eventSubEvents[subId];
        if (event) {
            delete this._eventSubEvents[subId];
            const subs = this._eventSubs[event];
            if (subs) {
                delete subs[subId];
            }
            this._eventSubIDMap.removeItem(subId); // Release subId
        }
    }
}

function resolvePath(key, json) {
    if (json[key]) {
        return json[key];
    }
    const parts = key.split(".");
    let obj = json;
    for (let i = 0, len = parts.length; obj && (i < len); i++) {
        const part = parts[i];
        obj = obj[part];
    }
    return obj;
}

function vsprintf(msg, args = []) {
    return msg.replace(/\{\{|\}\}|\{(\d+)\}/g, function (m, n) {
        if (m === "{{") {
            return "{";
        }
        if (m === "}}") {
            return "}";
        }
        return args[n];
    });
}

export {LocaleService};