model-explorer / js /ui /helpTooltip.js
mr4's picture
Upload 71 files
9bd422a verified
/**
* HelpTooltip - Displays help icons (?) next to section headers with multilingual popup explanations.
* Loads help content from a JSON file and supports EN/VI/JA language switching.
* Requirements: 28.1, 28.2, 28.3, 28.4, 28.5, 28.6, 28.7, 28.8, 28.9
*/
class HelpTooltip {
/**
* @param {Object} [options]
* @param {string} [options.helpContentPath] - Path to help JSON file (default: './help/help-content.json')
* @param {string} [options.defaultLang] - Default language code (default: 'en')
* @param {string} [options.storageKey] - localStorage key for language (default: 'onnx_explorer_help_lang')
*/
constructor(options = {}) {
this._helpContentPath = options.helpContentPath || './help/help-content.json';
this._defaultLang = options.defaultLang || 'en';
this._storageKey = options.storageKey || 'onnx_explorer_help_lang';
this._helpContent = null;
this._currentLang = this._loadLanguage();
this._currentPopup = null;
this._currentIconSection = null;
// Bound handler for closing popup on outside click
this._onDocumentClick = this._onDocumentClick.bind(this);
// Section key β†’ display name mapping
this._sectionNames = {
metadata: 'Metadata',
inputOutput: 'Inputs & Outputs',
initializers: 'Initializers',
layerStats: 'Layer Statistics',
modelComplexity: 'Model Complexity',
opsetCompatibility: 'Opset Compatibility',
modelGraph: 'Model Graph',
nodeDetails: 'Node Details'
};
// Section key β†’ container selector + header text for lookup
this._sectionMap = {
metadata: { selector: '#metadataContainer', headerText: 'Metadata' },
inputOutput: { selector: '#inputOutputContainer', headerText: 'Inputs & Outputs' },
initializers: { selector: '#initializerContainer', headerText: 'Initializers' },
layerStats: { selector: '#layerStatsContainer', headerText: 'Layer Statistics', noCard: true },
modelComplexity: { selector: '#modelComplexityContainer',headerText: 'Model Complexity', noCard: true },
opsetCompatibility: { selector: '#opsetCheckerContainer', headerText: 'Opset Compatibility', noCard: true },
modelGraph: { selector: null, headerText: 'Model Graph' },
nodeDetails: { selector: null, headerText: 'Node Details' }
};
}
// ─── Public API ───────────────────────────────────────────────────────────
/**
* Initialize: load help content JSON, inject icons into section headers.
* @returns {Promise<void>}
*/
async init() {
try {
this._helpContent = await this.loadHelpContent();
} catch (err) {
console.warn('[HelpTooltip] Failed to load help content:', err);
this._helpContent = null;
}
this._injectAllIcons();
document.addEventListener('click', this._onDocumentClick);
}
/**
* Fetch help content from JSON file.
* @returns {Promise<Object>}
*/
async loadHelpContent() {
const response = await fetch(this._helpContentPath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
/**
* Inject a help icon (?) next to a section header element.
* @param {string} sectionKey - Key of the section (e.g. 'metadata')
* @param {HTMLElement} headerElement - The heading element to inject icon next to
*/
injectIcon(sectionKey, headerElement) {
if (!headerElement) return;
// Avoid duplicate injection
if (headerElement.querySelector('.help-icon')) return;
const icon = document.createElement('span');
icon.className = 'help-icon';
icon.setAttribute('data-section', sectionKey);
icon.setAttribute('title', 'Help');
icon.setAttribute('role', 'button');
icon.setAttribute('tabindex', '0');
icon.setAttribute('aria-label', `Help for ${this._sectionNames[sectionKey] || sectionKey}`);
icon.textContent = '?';
icon.addEventListener('click', (e) => {
e.stopPropagation();
this.showPopup(sectionKey, icon);
});
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
this.showPopup(sectionKey, icon);
}
});
headerElement.appendChild(icon);
}
/**
* Show a help popup positioned near the anchor icon.
* @param {string} sectionKey - Key of the section
* @param {HTMLElement} anchorElement - The icon element to position popup near
*/
showPopup(sectionKey, anchorElement) {
// Close any existing popup
this.closePopup();
this._currentIconSection = sectionKey;
const popup = document.createElement('div');
popup.className = 'help-popup';
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', `Help: ${this._sectionNames[sectionKey] || sectionKey}`);
popup.innerHTML = this._buildPopupHTML(sectionKey);
document.body.appendChild(popup);
this._currentPopup = popup;
// Position popup near the icon
this._positionPopup(popup, anchorElement);
// Attach language button handlers
this._attachLangHandlers(popup, sectionKey);
// Attach close button handler
const closeBtn = popup.querySelector('.help-popup-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closePopup();
});
}
// Prevent clicks inside popup from closing it
popup.addEventListener('click', (e) => {
e.stopPropagation();
});
}
/**
* Close the current popup.
*/
closePopup() {
if (this._currentPopup && this._currentPopup.parentNode) {
this._currentPopup.parentNode.removeChild(this._currentPopup);
}
this._currentPopup = null;
this._currentIconSection = null;
}
/**
* Set the display language and persist to localStorage.
* @param {string} lang - Language code ('en', 'vi', 'ja')
*/
setLanguage(lang) {
if (['en', 'vi', 'ja'].indexOf(lang) === -1) return;
this._currentLang = lang;
this._saveLanguage(lang);
}
/**
* Get the current display language.
* @returns {string}
*/
getLanguage() {
return this._currentLang;
}
/**
* Destroy: remove event listeners, close popup, remove injected icons.
*/
destroy() {
this.closePopup();
document.removeEventListener('click', this._onDocumentClick);
// Remove all injected help icons
const icons = document.querySelectorAll('.help-icon');
icons.forEach((icon) => {
if (icon.parentNode) {
icon.parentNode.removeChild(icon);
}
});
}
// ─── Private ──────────────────────────────────────────────────────────────
/**
* Load saved language from localStorage, or return default.
* @returns {string}
*/
_loadLanguage() {
try {
const saved = localStorage.getItem(this._storageKey);
if (saved && ['en', 'vi', 'ja'].indexOf(saved) !== -1) {
return saved;
}
} catch (e) {
// localStorage unavailable
}
return this._defaultLang;
}
/**
* Save language to localStorage.
* @param {string} lang
*/
_saveLanguage(lang) {
try {
localStorage.setItem(this._storageKey, lang);
} catch (e) {
// localStorage unavailable
}
}
/**
* Inject help icons into all known section headers.
*/
_injectAllIcons() {
for (const sectionKey of Object.keys(this._sectionMap)) {
const config = this._sectionMap[sectionKey];
const headerEl = this._findHeaderElement(sectionKey, config);
if (headerEl) {
this.injectIcon(sectionKey, headerEl);
}
}
// For sections without card wrappers, observe for dynamic content
this._observeDynamicSections();
}
/**
* Find the header element for a given section.
* @param {string} sectionKey
* @param {Object} config - { selector, headerText, noCard }
* @returns {HTMLElement|null}
*/
_findHeaderElement(sectionKey, config) {
// For sections with a container selector and card wrapper
if (config.selector) {
const container = document.querySelector(config.selector);
if (!container) return null;
if (config.noCard) {
// No card wrapper β€” look for a heading inside the container
const heading = container.querySelector('h5, h6, .card-header h5, .card-header h6');
if (heading) return heading;
// Will be handled by MutationObserver
return null;
}
// Find the .card-header ancestor and its h5/h6
const card = container.closest('.card');
if (card) {
const header = card.querySelector('.card-header h5, .card-header h6');
if (header) return header;
}
}
// For sections without a selector (modelGraph, nodeDetails) β€” search by header text
if (!config.selector || !document.querySelector(config.selector)) {
const allHeaders = document.querySelectorAll('.card-header h5, .card-header h6');
for (const h of allHeaders) {
if (h.textContent.trim() === config.headerText) {
return h;
}
}
}
return null;
}
/**
* Observe dynamic sections (layerStats, modelComplexity, opsetCompatibility)
* that render content after init. Inject icons when headings appear.
*/
_observeDynamicSections() {
const dynamicKeys = ['layerStats', 'modelComplexity', 'opsetCompatibility'];
dynamicKeys.forEach((sectionKey) => {
const config = this._sectionMap[sectionKey];
if (!config.selector) return;
const container = document.querySelector(config.selector);
if (!container) return;
// Already injected?
if (container.querySelector('.help-icon')) return;
const observer = new MutationObserver(() => {
// Check if a heading appeared
const heading = container.querySelector('h5, h6, .card-header h5, .card-header h6');
if (heading && !heading.querySelector('.help-icon')) {
this.injectIcon(sectionKey, heading);
observer.disconnect();
}
});
observer.observe(container, { childList: true, subtree: true });
});
}
/**
* Handle document click β€” close popup if click is outside popup and icon.
* @param {Event} e
*/
_onDocumentClick(e) {
if (!this._currentPopup) return;
// Check if click is inside popup
if (this._currentPopup.contains(e.target)) return;
// Check if click is on a help icon
if (e.target.classList && e.target.classList.contains('help-icon')) return;
this.closePopup();
}
/**
* Build the inner HTML for the popup.
* @param {string} sectionKey
* @returns {string}
*/
_buildPopupHTML(sectionKey) {
const sectionName = this._sectionNames[sectionKey] || sectionKey;
const bodyText = this._getHelpText(sectionKey);
const langs = ['en', 'vi', 'ja'];
const langLabels = { en: 'EN', vi: 'VI', ja: 'JA' };
let langButtons = '';
langs.forEach((lang) => {
const active = lang === this._currentLang ? ' active' : '';
langButtons += `<button class="help-lang-btn${active}" data-lang="${lang}" title="${langLabels[lang]}">${langLabels[lang]}</button>`;
});
return `
<div class="help-popup-header">
<span class="help-popup-title">${this._escapeHtml(sectionName)}</span>
<button class="help-popup-close" title="Close" aria-label="Close">&times;</button>
</div>
<div class="help-popup-body">${this._escapeHtml(bodyText)}</div>
<div class="help-popup-footer">${langButtons}</div>
`;
}
/**
* Get help text for a section in the current language.
* @param {string} sectionKey
* @returns {string}
*/
_getHelpText(sectionKey) {
if (!this._helpContent) {
return 'Help content unavailable';
}
const section = this._helpContent[sectionKey];
if (!section) {
return 'Help content unavailable';
}
return section[this._currentLang] || section[this._defaultLang] || section['en'] || 'Help content unavailable';
}
/**
* Position the popup near the anchor element, ensuring it stays within viewport.
* @param {HTMLElement} popup
* @param {HTMLElement} anchor
*/
_positionPopup(popup, anchor) {
const rect = anchor.getBoundingClientRect();
const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
// Default: position below the icon
let top = rect.bottom + scrollY + 6;
let left = rect.left + scrollX;
// Apply position so we can measure popup dimensions
popup.style.position = 'absolute';
popup.style.top = top + 'px';
popup.style.left = left + 'px';
// Adjust if popup overflows right edge
const popupRect = popup.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (popupRect.right > viewportWidth - 10) {
left = Math.max(10, viewportWidth - popupRect.width - 10 + scrollX);
popup.style.left = left + 'px';
}
// Adjust if popup overflows bottom edge
const viewportHeight = window.innerHeight;
if (popupRect.bottom > viewportHeight - 10) {
// Position above the icon instead
top = rect.top + scrollY - popupRect.height - 6;
if (top < scrollY + 10) {
top = scrollY + 10;
}
popup.style.top = top + 'px';
}
}
/**
* Attach click handlers to language buttons inside the popup.
* @param {HTMLElement} popup
* @param {string} sectionKey
*/
_attachLangHandlers(popup, sectionKey) {
const buttons = popup.querySelectorAll('.help-lang-btn');
buttons.forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const lang = btn.getAttribute('data-lang');
this.setLanguage(lang);
// Update body text
const body = popup.querySelector('.help-popup-body');
if (body) {
body.textContent = this._getHelpText(sectionKey);
}
// Update active state on buttons
buttons.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
});
});
}
/**
* Escape HTML special characters.
* @param {string} str
* @returns {string}
*/
_escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(String(str)));
return div.innerHTML;
}
}
window.HelpTooltip = HelpTooltip;