Source: component/component.js

import { InformationSource } from '../reporter/information_source.js';
import { Reporter } from '../reporter/reporter.js';
import { Atom } from '../atom.js';
import { pick, UUIDRegex, URLRegex, unique } from '../lib.js';
import { Package } from '../package/package.js';


/** Base class for loadable components */

class Component extends InformationSource {

    /**
     * @param {Reporter} reporter
     * @param {Object} settings
     * @param {UUID} [settings.id]
     * @param {string} [settings.name]
     * @param {Boolean} [settings.debug]
     */

    constructor(reporter, settings) {

        super(reporter, pick(['id', 'name'], settings));

        if (!this.constructor._exportName) {
            throw new Error('Class constructor is missing a static exportName field');
        }

        this._resetContent();

        // remove debug flag from settings

        this._debug = settings.debug || false;

        delete settings.debug;


        // this object is used for export

        this._settings = settings;


        // events this class can emit

        this.addEvent('status-change');

        // subscribe to quality part status atoms
        // and emit an event on change

        for (let part of Component.parts) {

            for (let quality of Component.qualities) {
                // for (let quality of ['low','medium','high']) {

                const statusAtom = this._status[part][quality];

                statusAtom.on('change', ({ state, previousState, bubbleState }) => {

                    // if (this.constructor.name === 'MaterialCategoryType') {
                    //     // bubbleState = false;
                    //     console.log('on change MaterialCategoryType, bubbleState:', bubbleState);
                    // }

                    if (part === 'main' && quality === 'medium' && state === 'ready') {
                        this.report({ msg: `${quality} quality ${part} status: ${previousState} -> ${state}`, level: 'debug' });
                    }

                    if (state === 'error') {
                        console.error(this.label, `${quality} quality ${part} status: ${previousState} -> ${state}`);
                    }

                    if (bubbleState !== false) {
                        return this.emit('status-change', { quality, part, status: state, previousStatus: previousState, bubbleState });
                    }
                    else {
                        return true;
                    }
                });
            }
        }


        // make settings "gettable" from root level of component

        for (let setting of Object.keys(this._settings)) {

            if (setting === 'source') {
                // skip - this is a special case with regards to the LoadableComponent
                // which sets an augmented source object to the root later
                continue;
            }

            // this[ setting ]

            if (this[setting] === undefined && Object.getOwnPropertyDescriptor(this, setting) === undefined) {
                Object.defineProperty(
                    this,
                    setting,
                    {
                        get: () => {
                            return this._settings[setting];
                        }

                    }
                )
                // this.setting
            }
        }
    }


    /**
     * @static @interface
     * @type {ExportLevel}
     */

    static _exportLevel = 'root';

    static get exportLevel() {
        return this._exportLevel;
    }

    get exportLevel() {
        return this.constructor.exportLevel;
    }


    /**
     * @static @interface
     * @type {Object}
     * @property {string} singular
     * @property {string} plural
     */

    static _exportName = {
        singular: 'component',
        plural: 'components'
    };


    // default export name

    static get exportName() {
        return this._exportName.plural;
    }

    get exportName() {
        return this.constructor.exportName;
    }


    /**
     * @type {Array<LoadingQuality>}
     */

    static qualities = ['medium'];  // removed other qualities while we're not using them


    /**
     * @type {Array<ComponentPart>}
     */

    static parts = ['UI', 'main', 'specs'];


    /**
     * Builds a component part quality atom
     * @param {string} name 
     * @param {Reporter} reporter 
     * @returns {Atom}
     */

    static partQualityStatusAtom(name, reporter) {
        return new Atom(
            reporter,
            `${name} status`,
            {
                'not-loaded': ['loading', 'partially-loaded', 'ready', 'error'],
                'loading': ['partially-loaded', 'ready', 'error'],
                'partially-loaded': ['not-loaded', 'loading', 'ready', 'error'], // a package can start out empty (and so, ready) and then load a json and be "not-loaded"
                'ready': ['not-loaded', 'loading', 'partially-loaded', 'error'],  // in case a component is added to a tree during runtime
                'error': []
            },
            'not-loaded'
        );
    }


    /**
     * Inner status tracking
     * @protected
     * @todo object props should not be hardcoded
     */

    _status = {
        main: {
            // high: Component.partQualityStatusAtom(`${this.label} high quality main`, this._reporter),
            medium: Component.partQualityStatusAtom(`${this.label} medium quality main`, this._reporter),
            // low: Component.partQualityStatusAtom(`${this.label} low quality main`, this._reporter),
        },
        UI: {
            // high: Component.partQualityStatusAtom(`${this.label} high quality UI`, this._reporter),
            medium: Component.partQualityStatusAtom(`${this.label} medium quality UI`, this._reporter),
            // low: Component.partQualityStatusAtom(`${this.label} low quality UI`, this._reporter),
        },
        specs: {
            // high: Component.partQualityStatusAtom(`${this.label} high quality specs`, this._reporter),
            medium: Component.partQualityStatusAtom(`${this.label} medium quality specs`, this._reporter),
            // low: Component.partQualityStatusAtom(`${this.label} low quality specs`, this._reporter),
        }
    };


    /** 
     * Component status
     * @type {ComponentStatus}
     * @todo object props should not be hardcoded
     */

    get status() {
        return {
            main: {
                // high: this._status.main.high.state,
                medium: this._status.main.medium.state,
                // low: this._status.main.low.state,
            },
            UI: {
                // high: this._status.UI.high.state,
                medium: this._status.UI.medium.state,
                // low: this._status.UI.low.state,
            },
            specs: {
                // high: this._status.specs.high.state,
                medium: this._status.specs.medium.state,
                // low: this._status.specs.low.state,
            }
        }
    }


    /** @type {Boolean} */

    get materializable() {
        // overwritten in positioned component and block instance
        return this._settings.materialVariantGroup !== undefined;
    }


    /** 
     * Debug flag
     * @type {Boolean}
     */

    _debug = false;


    /**
     * 3D Object to use as placeholder in the scene
     * @type {Object3D}
     */

    mainPlaceholder;


    /** 
     * @protected
     * @type {Object}
     */

    _settings;


    /**
     * @type {Object}
     */

    get settings() {
        return this._settings;
    }


    /**
     * The name under which this component will be exported.
     * @type {string}
     */

    // exportName;


    /**
     * If "root" component be exported in the root of the exported
     * json. If a "Component" it will be exported inline.
     * @type {string}
     * @default 'root'
     */

    // exportEntry = 'root';


    /**
     * @protected
     * @param {ComponentTree} _tree
     */

    _tree;

    static treeLock = true;


    /**
     * @protected
     * @interface
     * @method
     */

    _onTreeSet() { }


    /**
     * @protected
     * @interface
     * @method
     */

    _onTreeUnset() { };


    /**
     * @type {ComponentTree}
     */

    get tree() {
        return this._tree;
    }

    set tree(treeToSet) {
        if (treeToSet === undefined) {

            this._onTreeUnset();

            this._tree = undefined;

        } else {
            if (this._tree !== undefined && this.constructor.treeLock === true) {
                throw new Error(`${this.label} tree property already set.`)
            }

            /** @type {ComponentTree} */

            this._tree = treeToSet;

            this._onTreeSet();
        }

    }


    /**
     * The "usable" part of this component. The product. The fruit.
     * @type {ComponentContent}
     * @protected
     * @todo object props should not be hardcoded
     */

    _content = {};

    get content() {
        return this._content;
    }


    _resetContent = () => {
        this._content = {
            main: {
                // low: null,
                medium: null,
                // high: null
            },
            UI: {
                // low: null,
                medium: null,
                // high: null
            },
            specs: {
                // low: null,
                medium: null,
                // high: null
            }
        };
    }







    /**
     * Error register.
     * @type {ComponentContent}
     * @protected
     * @todo object props should not be hardcoded
     */

    _error = {
        main: {
            // low: null,
            medium: null,
            // high: null
        },
        UI: {
            // low: null,
            medium: null,
            // high: null
        },
        specs: {
            // low: null,
            medium: null,
            // high: null
        }
    };

    get error() {
        return this._error;
    }


    /**
     * Set content and update status to "ready"
     * @protected
     * @method
     * @param {ComponentPart} part
     * @param {LoadingQuality} quality
     * @param {any} content
     * @returns {Promise<Array<any>>}
     */

    _setContent(part, quality, content, bubbleState = true) {
        this._content[part][quality] = content;
        return this._status[part][quality].setState('ready', bubbleState);
    }


    /**
     * Set error and update status to "error"
     * @protected
     * @method
     * @param {ComponentPart} part
     * @param {LoadingQuality} quality
     * @param {Error} error
     * @returns {Promise<Array<any>>}
     */

    _setError(part, quality, error) {
        if (this._error[part][quality] !== null) {
            console.error(error);
            throw new Error(`Can not set ${this.label} ${part} ${quality} to error state twice. New error ignored.`);
        }
        this._error[part][quality] = error;
        return this._status[part][quality].setState('error');
    }


    // get mainContent() {
    //     return this._content.main;
    // }

    // get mainReadyContent() {
    //     // get highest quality ready content
    //     return this._content.main;
    // }

    // get UIContent() {
    //     return this._content.UI;
    // }

    // get UIReadyContent() {
    //     // get highest quality ready content
    //     return this._content.UI;
    // }


    clone(newSettings = {}) {
        return new this.constructor(this._reporter, { ...this._settings, ...newSettings });
    }


    /**
     * Relative value of states
     * @type {Object<ComponentPartQualityStatus,number>} 
     */

    static loadStatusSortMap = {
        'not-loaded': 1,
        'loading': 2,
        'partially-loaded': 3,
        'ready': 4,
        'error': 5,
    };



    /**
     * Sorts loading status arrays high to low
     * @static
     * @param {Array<ComponentPartQualityStatus>} arr 
     * @returns {Array<ComponentPartQualityStatus>}
     */

    static sortStatusArray(arr) {
        return arr.slice().sort((a, b) => Component.loadStatusSortMap[b] - Component.loadStatusSortMap[a]);
    }



    /**
     * Relative value of loading qualities
     * @type {Object<LoadingQuality,number>} 
     */

    static qualitySortMap = {
        high: 3,
        medium: 2,
        low: 1
    };


    /**
     * Sorts quality arrays high to low
     * @static
     * @param {Array<LoadingQuality>} arr 
     * @returns {Array<LoadingQuality>}
     */

    static sortQualityArray(arr) {
        return arr.slice().sort((a, b) => Component.qualitySortMap[b] - Component.qualitySortMap[a]);
    }

    static highestQualityLoaded(contentObject) {
        return contentObject.high || contentObject.medium || contentObject.low || null;
    }



    static unlink(val) {
        if (Array.isArray(val)) {
            return val.map(Component.unlink);
        }
        else if (val instanceof Package) {
            // ignore
            return undefined;
        }
        else if (val instanceof Component) {
            return val.id;
        }
        else if (val instanceof URL) {
            return val.href;
        }
        else if (typeof val === 'object') {
            return Object.entries(val).reduce(
                (objToReturn, [key, subVal]) =>
                    Object.assign(objToReturn, { [key]: Component.unlink(subVal) }),
                {}
            );
        }
        else {
            return val;
        }
    }



    /**
     * Export to JSON
     * @return {Object}
     */

    toJSON() {

        // this.report({
        //     msg: 'Export start'
        // });

        const selfExport = Object.entries(this._settings)
            .reduce(
                (obj, [prop, val]) => {
                    const unlinkedValue = Component.unlink(val);
                    if (unlinkedValue === undefined) {
                        return obj;
                    }
                    else {
                        obj[prop] = unlinkedValue;
                        return obj;
                    }
                },
                {
                    id: this.id,
                    name: this.name
                }
            );

        // console.log(this.label, this.constructor.name)

        if (this.constructor.name === 'Configuration') {

            if (!this.assignedMaterials) {
                console.warn('Not exporting assigned materials, configuration not build yet');
            }
            else {
                const materialOverview = {};

                for (let bi of this.blockInstances || []) {

                    materialOverview[bi.id] = {
                        assignables: [],
                        block: {
                            id: bi.block.id,
                            name: bi.block.name,
                        }
                    };

                    for (let assignedMatObj of this.assignedMaterials[bi.id] || []) {

                        const assignable = { assignments: [] };

                        for (let [mvgId, material] of Object.entries(assignedMatObj.mvg)) {

                            const mvg = this.pkg.findComponentById(mvgId);

                            const assignment = {
                                mvg: {
                                    id: mvgId,
                                    name: mvg.name
                                },
                                material: {
                                    id: material.id,
                                    name: material.name
                                }
                            };

                            assignable.assignments.push(assignment);
                        }

                        if (assignedMatObj.positionedMeshGroup) {
                            assignable.positionedMeshGroup = {
                                id: positionedMeshGroup.id,
                                name: positionedMeshGroup.id
                            }
                        }

                        if (assignedMatObj.positionedMesh) {
                            assignable.positionedMesh = {
                                id: positionedMesh.id,
                                name: positionedMesh.id
                            }
                        }

                        // assignable.block = {
                        //     id: bi.block.id,
                        //     name: bi.block.name,
                        // };

                        materialOverview[bi.id].assignables.push(assignable);
                    }
                }
                selfExport.materialOverview = materialOverview;
                // console.log(materialOverview)
            }

        }




        // if there is only one source, do not export the
        // quality indicator "medium"

        if (this._settings && this._settings.source && Object.keys(this._settings.source).length === 1) {
            selfExport['source'] = this._settings.source.medium;
        }


        const exportObject = {
            [this.exportName]: [selfExport]
        };


        // inline components have circular dependencies, 
        // so those are not exported

        // if ( this.exportEntry === 'root' ) {

        // getting around the type checker..
        // if (this instanceof BuildableComponent) ..

        for (let part of Component.parts) {

            const partDependencies = this['dependencies'] ? Object.values(this['dependencies'][part]) : [];

            if (part === 'main' && this['_exportOnlyDependencies']) {

                const exportOnlyDeps = Object.values(this['_exportOnlyDependencies']);

                if (exportOnlyDeps.length > 0) {
                    partDependencies.push(...exportOnlyDeps);
                }
            }

            if (partDependencies.length > 0) {

                const partDepsFlat = partDependencies.flat();

                let order = [];

                if (this.constructor.name === 'ComponentTree') {
                    order = this.constructor.importInstructions.map(obj => obj.cls);
                }
                else if (this._tree) {
                    order = this._tree.constructor.importInstructions.map(obj => obj.cls);
                }
                else {
                    order = partDepsFlat.filter(unique).map(obj => obj.constructor)
                }

                // console.log(order)

                for (let cls of order) {

                    const clsDeps = partDepsFlat.filter(dep => dep.constructor === cls);

                    for (let dep of clsDeps) {

                        // for (let dep of partDependencies.flat()) {

                        // console.log('Export', dep.label)

                        if (dep.constructor.name === 'ComponentTree') {
                            this.report({ msg: 'Not exporting external component tree ' + ComponentTree.label });
                            continue;
                        }
                        else if (dep.exportName === 'configurations') {
                            exportObject.configurations = exportObject.configurations || [];
                            exportObject.configurations.push(dep.toJSON())
                            continue;
                        }

                        const depExport = dep.toJSON();

                        // console.log( dep.label, dep.exportLevel );
                        // console.log( depExport );

                        // if ( dep.exportEntry !== 'root' ) {

                        //     // export inlined

                        //     const depExportData = depExport[ dep.exportName ][ 0 ];

                        //                 // since the dependency is exported under this component
                        //                 // an entry specifying the super component's id is
                        //                 // superfluous

                        //                 // delete depExportData[ dep.componentName ];

                        //     selfExport[ dep.exportName ] = selfExport[ dep.exportName ] || [];
                        //     selfExport[ dep.exportName ].push( depExportData );
                        // }
                        // else {

                        // export in root

                        for (let [entry, components] of Object.entries(depExport)) {

                            if (dep.exportLevel !== 'root' && entry === dep.exportName) {
                                // console.log(JSON.stringify(exportObject))
                                selfExport[dep.exportName] = selfExport[dep.exportName] || [];
                                selfExport[dep.exportName].push(...components);

                                // remove the "old" id entry
                                const idIndex = selfExport[dep.exportName].indexOf(dep.id);
                                selfExport[dep.exportName].splice(idIndex, 1);

                                delete exportObject[dep.exportName];

                                // console.log(JSON.stringify(exportObject))

                                // console.log('Removed exported', dep.label, 'from root', selfExport)

                            }
                            else {
                                exportObject[entry] = exportObject[entry] || [];

                                const newComponents = components
                                    .filter(c => exportObject[entry].find(existingComponent => existingComponent.id === c.id) === undefined)
                                    ;

                                exportObject[entry].push(...newComponents);
                            }
                        }
                    }
                }
                // console.log(exportObject)
            }
        }
        // }

        for (let [name, val] of Object.entries(exportObject)) {
            if (Array.isArray(val)) {
                val.sort((a, b) => (a.id > b.id) ? 1 : -1);
            }
        }

        return exportObject;
    };

    destroyed = false;

    destroy() {

        // console.log('destroy', this.label);

        if (this.destroyed !== false) {
            console.warn(this.label, 'was already destroyed');
            return;
        }

        this.destroyed = true;

        for (let part of Component.parts) {

            for (let quality of Component.qualities) {

                const statusAtom = this._status[part][quality];

                statusAtom.removeAllListeners();

                if (this._content[part][quality]) {
                    if (typeof (this._content[part][quality].dispose) === 'function') {
                        this._content[part][quality].dispose();
                        // console.log('dispose', this.label, part, quality)
                    }
                }
            }
        }

        this._resetContent();

        for (let setting of Object.keys(this.settings)) {
            if(! ['options'].includes(setting)){
                this.settings[setting] = undefined;
            }
        }

        for (let prop of Object.keys(this)) {
            // console.log('del props', this.label)
            this[prop] = undefined;
        }

        if (super.destroy) {
            super.destroy();
        }
    }
};

export { Component };