/**
* 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 };