core/glossary.js

/**
 * GlossaryManager — Loads glossary data and provides tooltip controller.
 *
 * Responsibilities:
 * 1. Load & cache glossary terms from config/glossary.json
 * 2. Provide query API (getById, getByCategory, search)
 * 3. Scan DOM for data-glossary annotations and attach tooltip behavior
 *
 * Usage:
 *   const gm = await GlossaryManager.load('../');          // from html/ subfolder
 *   gm.initTooltips();                                      // activate on current page
 *   const term = gm.getById('suchbaum');
 *
 * @fileoverview
 * @author Alexander Wolf
 * @version 1.0
 * @see docs/conventions/DATA_MODEL_CONVENTIONS.md §3
 * @see docs/conventions/ENGINEERING_CONVENTIONS.md §10
 */

/** @type {GlossaryManager|null} Singleton instance */
let _instance = null;

/**
 * Central glossary manager for ai-unboxed.
 */
class GlossaryManager {

    /**
     * @param {Object} data - Parsed glossary.json data
     * @param {string} basePath - Base path for resolving relative URLs
     */
    constructor(data, basePath) {
        /** @type {number} */
        this.version = data.version;

        /** @type {Object<string, string>} */
        this.categoryLabels = data.categoryLabels || {};

        /** @type {Map<string, Object>} ID → term object */
        this._termsById = new Map();

        /** @type {Map<string, Set<string>>} alias/term → ID (lowercase) */
        this._aliasIndex = new Map();

        /** @type {Map<string, Object[]>} category → terms */
        this._byCategory = new Map();

        /** @type {string} */
        this._basePath = basePath;

        /** @type {HTMLElement|null} Active tooltip element */
        this._tooltipEl = null;

        /** @type {number|null} Hover timer */
        this._hoverTimer = null;

        // Build indices
        for (const term of (data.terms || [])) {
            this._termsById.set(term.id, term);

            // Alias index (lowercase)
            const lowerTerm = term.term.toLowerCase();
            this._aliasIndex.set(lowerTerm, term.id);
            if (term.aliases) {
                for (const alias of term.aliases) {
                    this._aliasIndex.set(alias.toLowerCase(), term.id);
                }
            }

            // Category index
            if (!this._byCategory.has(term.category)) {
                this._byCategory.set(term.category, []);
            }
            this._byCategory.get(term.category).push(term);
        }
    }

    /**
     * Async factory — loads glossary data and returns singleton.
     * @param {string} [basePath=''] - Path prefix to config/glossary.json
     * @returns {Promise<GlossaryManager>}
     */
    static async load(basePath = '') {
        if (_instance) return _instance;

        const url = `${basePath}config/glossary.json`;
        const resp = await fetch(url);
        if (!resp.ok) {
            throw new Error(`GlossaryManager: Failed to load ${url} (${resp.status})`);
        }
        const data = await resp.json();
        _instance = new GlossaryManager(data, basePath);
        return _instance;
    }


    /* ═══════════════════════════════════════════════
       QUERY API
       ═══════════════════════════════════════════════ */

    /**
     * Get a term by its ID.
     * @param {string} id
     * @returns {Object|null}
     */
    getById(id) {
        return this._termsById.get(id) || null;
    }

    /**
     * Get all terms.
     * @returns {Object[]}
     */
    getAllTerms() {
        return Array.from(this._termsById.values());
    }

    /**
     * Get terms by category.
     * @param {string} category
     * @returns {Object[]}
     */
    getByCategory(category) {
        return this._byCategory.get(category) || [];
    }

    /**
     * Get all category names (sorted).
     * @returns {string[]}
     */
    getCategories() {
        return Array.from(this._byCategory.keys()).sort();
    }

    /**
     * Full-text search over terms, aliases, short and long definitions.
     * @param {string} query
     * @returns {Object[]} Matching terms, scored by relevance
     */
    search(query) {
        if (!query || !query.trim()) return this.getAllTerms();

        const q = query.trim().toLowerCase();
        const results = [];

        for (const term of this._termsById.values()) {
            let score = 0;

            // Exact ID match
            if (term.id === q) score += 10;

            // Term name
            if (term.term.toLowerCase().includes(q)) score += 5;

            // Aliases
            if (term.aliases && term.aliases.some(a => a.toLowerCase().includes(q))) {
                score += 4;
            }

            // Short definition
            if (term.short.toLowerCase().includes(q)) score += 2;

            // Long definition
            if (term.long && term.long.toLowerCase().includes(q)) score += 1;

            if (score > 0) {
                results.push({ term, score });
            }
        }

        return results
            .sort((a, b) => b.score - a.score)
            .map(r => r.term);
    }

    /**
     * Resolve a term from text (matches against term names and aliases).
     * @param {string} text
     * @returns {Object|null}
     */
    resolve(text) {
        const id = this._aliasIndex.get(text.toLowerCase());
        return id ? this._termsById.get(id) : null;
    }


    /* ═══════════════════════════════════════════════
       TOOLTIP CONTROLLER
       ═══════════════════════════════════════════════ */

    /**
     * Scan the DOM for [data-glossary] elements and attach tooltip behavior.
     * Call once after page load.
     */
    initTooltips() {
        const annotatedEls = document.querySelectorAll('[data-glossary]');
        if (annotatedEls.length === 0) return;

        // Create tooltip element (reused)
        this._createTooltipElement();

        annotatedEls.forEach(el => {
            const termId = el.getAttribute('data-glossary');
            const term = this.getById(termId);
            if (!term) return;

            // Ensure the element has the glossary-term class
            el.classList.add('glossary-term');
            el.setAttribute('tabindex', '0');
            el.setAttribute('role', 'button');
            el.setAttribute('aria-label', `Glossar: ${term.term}`);

            // Hover
            el.addEventListener('mouseenter', () => this._showTooltipDelayed(el, term));
            el.addEventListener('mouseleave', () => this._hideTooltip());
            el.addEventListener('focus', () => this._showTooltipDelayed(el, term));
            el.addEventListener('blur', () => this._hideTooltip());

            // Click → navigate to glossary page
            el.addEventListener('click', (e) => {
                e.preventDefault();
                window.location.href = `${this._basePath}html/glossary.html#${termId}`;
            });
        });
    }

    /** @private */
    _createTooltipElement() {
        if (this._tooltipEl) return;

        const tip = document.createElement('div');
        tip.className = 'glossary-tooltip';
        tip.setAttribute('role', 'tooltip');
        tip.innerHTML = `
            <div class="glossary-tooltip__term">
                <span class="glossary-tooltip__term-text"></span>
                <span class="glossary-tooltip__english"></span>
            </div>
            <div class="glossary-tooltip__short"></div>
            <a class="glossary-tooltip__more">Mehr erfahren →</a>
            <span class="glossary-tooltip__category"></span>
        `;
        document.body.appendChild(tip);
        this._tooltipEl = tip;

        // Keep tooltip visible while hovering over it
        tip.addEventListener('mouseenter', () => this._clearHideTimer());
        tip.addEventListener('mouseleave', () => this._hideTooltip());
    }

    /** @private */
    _showTooltipDelayed(el, term) {
        this._clearHideTimer();
        this._hoverTimer = setTimeout(() => this._showTooltip(el, term), 300);
    }

    /** @private */
    _showTooltip(el, term) {
        if (!this._tooltipEl) return;

        const tip = this._tooltipEl;
        tip.querySelector('.glossary-tooltip__term-text').textContent = term.term;

        const englishEl = tip.querySelector('.glossary-tooltip__english');
        if (term.aliases && term.aliases.length > 0) {
            englishEl.textContent = `(${term.aliases[0]})`;
            englishEl.hidden = false;
        } else {
            englishEl.hidden = true;
        }

        tip.querySelector('.glossary-tooltip__short').textContent = term.short;

        const moreLink = tip.querySelector('.glossary-tooltip__more');
        moreLink.href = `${this._basePath}html/glossary.html#${term.id}`;

        const catLabel = this.categoryLabels[term.category] || term.category;
        tip.querySelector('.glossary-tooltip__category').textContent = catLabel;

        // Position
        const rect = el.getBoundingClientRect();
        const tipWidth = 360;
        let left = rect.left + rect.width / 2 - tipWidth / 2;
        left = Math.max(8, Math.min(left, window.innerWidth - tipWidth - 8));
        let top = rect.bottom + 8;

        // Flip above if not enough space below
        if (top + 200 > window.innerHeight) {
            top = rect.top - 8;
            tip.style.transform = 'translateY(-100%)';
        } else {
            tip.style.transform = '';
        }

        tip.style.left = `${left}px`;
        tip.style.top = `${top}px`;
        tip.style.width = `${tipWidth}px`;
        tip.classList.add('glossary-tooltip--visible');
    }

    /** @private */
    _hideTooltip() {
        this._clearHideTimer();
        this._hoverTimer = setTimeout(() => {
            if (this._tooltipEl) {
                this._tooltipEl.classList.remove('glossary-tooltip--visible');
            }
        }, 150);
    }

    /** @private */
    _clearHideTimer() {
        if (this._hoverTimer) {
            clearTimeout(this._hoverTimer);
            this._hoverTimer = null;
        }
    }
}


export { GlossaryManager };