UNPKG

tapircharts

Version:

A lightweight, customizable JavaScript charting framework designed for dashboard applications with multiple charts sharing themes

1,348 lines (1,185 loc) 415 kB
/** * TapirCharts - Complete Browser Bundle * Version: 1.0.0 * All classes included for browser usage */ // === ThemeManager.js === /** * ThemeManager - Singleton pattern for global theme management * Manages themes across all chart instances with coordinated updates */ class ThemeManager { constructor() { if (ThemeManager.instance) { return ThemeManager.instance; } this.themes = { default: { backgroundColor: '#ffffff', gridColor: '#d0d0d0', textColor: '#333333', barColors: ['#006EDB', '#008060', 'hsl(40, 100%, 50%)', '#D72C0D', 'hsl(270, 45%, 52%)'], axisColor: '#666666', tooltipBackground: '#ffffff', tooltipText: '#333333', tooltipBorder: '#e0e0e0', // Chart Configuration Layer chart: { margin: { top: 55, right: 20, bottom: 40, left: 60 }, barWidth: 'auto', barSpacing: 4, barCornerRadius: 2, minBarWidth: 8, maxBarWidth: 60, fontSize: 12, titleFontSize: 16, labelRotation: 0, barBorder: false, barBorderWidth: 1 } }, dark: { backgroundColor: '#1a1a1a', gridColor: '#333333', textColor: '#ffffff', barColors: ['hsl(210, 100%, 75%)', '#008060', 'hsl(40, 100%, 60%)', 'hsl(12, 80%, 55%)', 'hsl(270, 45%, 65%)'], axisColor: '#888888', tooltipBackground: '#ffffff', tooltipText: '#333333', tooltipBorder: '#e0e0e0', // Chart Configuration Layer - Optimized for dark theme chart: { margin: { top: 60, right: 25, bottom: 45, left: 65 }, barWidth: 'auto', barSpacing: 6, barCornerRadius: 3, minBarWidth: 10, maxBarWidth: 70, fontSize: 12, titleFontSize: 18, labelRotation: 0, barBorder: false, barBorderWidth: 1 } }, minimal: { backgroundColor: '#fafafa', gridColor: '#d5d5d5', textColor: '#555555', barColors: ['#2c3e50', '#34495e', '#7f8c8d', '#95a5a6', '#bdc3c7'], axisColor: '#bdc3c7', tooltipBackground: '#ffffff', tooltipText: '#2c3e50', tooltipBorder: '#bdc3c7', // Chart Configuration Layer - Minimal spacing chart: { margin: { top: 50, right: 15, bottom: 35, left: 50 }, barWidth: 'auto', barSpacing: 2, barCornerRadius: 1, minBarWidth: 6, maxBarWidth: 50, fontSize: 11, titleFontSize: 14, labelRotation: 0, barBorder: false, barBorderWidth: 1 } }, gmf: { // GMF Framework integration theme // Uses CSS custom properties for dynamic theming backgroundColor: 'var(--color-background, #f5f5f5)', gridColor: 'var(--color-border, #e0e0e0)', textColor: 'var(--color-text, #333333)', barColors: [ 'var(--color-primary, #006EDB)', 'var(--color-success, #008060)', 'var(--color-warning, hsl(40, 100%, 50%))', 'var(--color-critical, #D72C0D)', 'var(--color-violet-600, hsl(270, 45%, 52%))', 'var(--color-blue-500, hsl(210, 100%, 50%))', 'var(--color-green-500, hsl(160, 100%, 45%))', 'var(--color-red-500, hsl(12, 80%, 50%))' ], axisColor: 'var(--color-border, #e0e0e0)', tooltipBackground: 'var(--color-surface, #ffffff)', tooltipText: 'var(--color-text, #333333)', tooltipBorder: 'var(--color-border, #e0e0e0)', // Chart Configuration Layer - Uses GMF spacing chart: { margin: { top: 55, right: 20, bottom: 40, left: 60 }, barWidth: 'auto', barSpacing: 8, // var(--space-2xs) equivalent barCornerRadius: 4, // var(--radius-xs) equivalent minBarWidth: 8, maxBarWidth: 60, fontSize: 13, // Based on GMF --step-0 titleFontSize: 16, // Based on GMF --step-1 labelRotation: 0, barBorder: false, barBorderWidth: 1 }, // Extended GMF Color Mappings colorMappings: { // Semantic colors that adapt to light/dark themes positive: 'var(--color-success, #008060)', negative: 'var(--color-critical, #D72C0D)', neutral: 'var(--color-text-subdued, #666666)', accent: 'var(--color-primary, #006EDB)', highlight: 'var(--color-highlight, hsl(245, 100%, 60%))', // State colors hover: 'var(--color-primary-hovered, hsl(210, 100%, 35%))', pressed: 'var(--color-primary-pressed, hsl(210, 100%, 30%))', disabled: 'var(--color-text-disabled, hsl(210, 10%, 60%))', // Surface variations surfaceLight: 'var(--color-surface-subdued, hsl(210, 10%, 95%))', surfaceDepressed: 'var(--color-surface-depressed, hsl(210, 10%, 95%))', // Chart-specific mappings dataColors: { series1: 'var(--color-primary, #006EDB)', series2: 'var(--color-success, #008060)', series3: 'var(--color-warning, hsl(40, 100%, 50%))', series4: 'var(--color-critical, #D72C0D)', series5: 'var(--color-violet-600, hsl(270, 45%, 52%))', series6: 'var(--color-blue-500, hsl(210, 100%, 50%))', series7: 'var(--color-green-500, hsl(160, 100%, 45%))', series8: 'var(--color-red-500, hsl(12, 80%, 50%))' } } } }; this.currentTheme = 'default'; this.observers = new Set(); this.customThemes = new Map(); this.configPresets = new Map(); ThemeManager.instance = this; // Initialize configuration presets this.initializePresets(); } static getInstance() { if (!ThemeManager.instance) { ThemeManager.instance = new ThemeManager(); } return ThemeManager.instance; } /** * Get current theme * @returns {Object} Current theme object */ getCurrentTheme() { return this.themes[this.currentTheme] || this.customThemes.get(this.currentTheme); } /** * Get a GMF color mapping by semantic name * @param {string} colorType - The semantic color type (e.g., 'positive', 'negative', 'accent') * @returns {string} CSS color value or variable */ getGMFColor(colorType) { const gmfTheme = this.themes.gmf; if (gmfTheme && gmfTheme.colorMappings) { return gmfTheme.colorMappings[colorType] || null; } return null; } /** * Get a data series color from GMF mappings * @param {number} seriesIndex - Zero-based series index * @returns {string} CSS color value or variable */ getGMFSeriesColor(seriesIndex) { const gmfTheme = this.themes.gmf; if (gmfTheme && gmfTheme.colorMappings && gmfTheme.colorMappings.dataColors) { const seriesKey = `series${seriesIndex + 1}`; return gmfTheme.colorMappings.dataColors[seriesKey] || gmfTheme.barColors[seriesIndex % gmfTheme.barColors.length]; } return this.getCurrentTheme().barColors[seriesIndex % this.getCurrentTheme().barColors.length]; } /** * Get specific theme by name * @param {string} themeName - Name of the theme * @returns {Object} Theme object or null if not found */ getTheme(themeName) { return this.themes[themeName] || this.customThemes.get(themeName) || null; } /** * Get theme chart configuration with fallback * @param {string} themeName - Optional theme name (uses current if not provided) * @returns {Object} Chart configuration from theme */ getThemeChartConfig(themeName = null) { const theme = themeName ? this.getTheme(themeName) : this.getCurrentTheme(); return theme?.chart || this.themes.default.chart; } /** * Merge theme chart config with user overrides * @param {Object} userConfig - User configuration overrides * @param {string} themeName - Optional theme name * @returns {Object} Merged configuration */ mergeChartConfig(userConfig = {}, themeName = null) { const themeConfig = this.getThemeChartConfig(themeName); return this.deepMerge(themeConfig, userConfig); } /** * Deep merge utility * @param {Object} target - Target object * @param {Object} source - Source object * @returns {Object} Merged object */ deepMerge(target, source) { const result = { ...target }; for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = this.deepMerge(result[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; } /** * Set global theme * @param {string} themeName - Name of the theme to set * @returns {boolean} Success status */ setGlobalTheme(themeName) { if (this.themes[themeName] || this.customThemes.has(themeName)) { this.currentTheme = themeName; this.notifyObservers(); return true; } return false; } /** * Add custom theme * @param {string} name - Theme name * @param {Object} theme - Theme object */ addCustomTheme(name, theme) { // Ensure theme has chart config with defaults if (!theme.chart) { theme.chart = { ...this.themes.default.chart }; } else { // Merge with defaults to ensure all properties exist theme.chart = this.deepMerge(this.themes.default.chart, theme.chart); } this.customThemes.set(name, theme); } /** * Remove custom theme * @param {string} name - Theme name * @returns {boolean} Success status */ removeCustomTheme(name) { return this.customThemes.delete(name); } /** * Get available theme names * @returns {Array} Array of theme names */ getAvailableThemes() { return [...Object.keys(this.themes), ...this.customThemes.keys()]; } /** * Register chart as observer * @param {ChartFramework} chart - Chart instance to register */ registerObserver(chart) { this.observers.add(chart); } /** * Unregister chart observer * @param {ChartFramework} chart - Chart instance to unregister */ unregisterObserver(chart) { this.observers.delete(chart); } /** * Check if GMF CSS Framework is available * @returns {boolean} True if GMF variables are detected */ isGMFAvailable() { if (typeof window === 'undefined' || !window.getComputedStyle) { return false; } const testElement = document.createElement('div'); document.body.appendChild(testElement); const styles = window.getComputedStyle(testElement); const hasGMFVars = styles.getPropertyValue('--color-primary').trim() !== ''; document.body.removeChild(testElement); return hasGMFVars; } /** * Auto-detect and use GMF theme if available * @returns {boolean} True if GMF theme was auto-applied */ autoDetectGMF() { if (this.isGMFAvailable()) { this.setGlobalTheme('gmf'); return true; } return false; } /** * Notify all observers of theme change */ notifyObservers() { this.observers.forEach(chart => { if (chart && typeof chart.updateTheme === 'function') { chart.updateTheme(); } }); } /** * Get theme color by index (for multiple series) * @param {number} index - Color index * @returns {string} Color value */ getColorByIndex(index) { const theme = this.getCurrentTheme(); return theme.barColors[index % theme.barColors.length]; } /** * Reset to default theme */ resetToDefault() { this.setGlobalTheme('default'); } /** * Initialize built-in configuration presets */ initializePresets() { // Dashboard preset - compact for multiple charts this.configPresets.set('dashboard', { margin: { top: 15, right: 15, bottom: 30, left: 45 }, barWidth: 'auto', barSpacing: 3, showValues: false, showGrid: true, responsive: false, fontSize: 11 }); // Showcase preset - large, prominent display this.configPresets.set('showcase', { margin: { top: 30, right: 30, bottom: 50, left: 70 }, barWidth: 50, barSpacing: 15, showValues: true, showGrid: true, fontSize: 14, titleFontSize: 20 }); // Compact preset - minimal space usage this.configPresets.set('compact', { margin: { top: 10, right: 10, bottom: 20, left: 35 }, barWidth: 18, barSpacing: 2, showValues: false, showGrid: false, fontSize: 10, titleFontSize: 12 }); // Report preset - clean, professional look this.configPresets.set('report', { margin: { top: 25, right: 25, bottom: 45, left: 60 }, barWidth: 'auto', barSpacing: 8, showValues: true, showGrid: true, barBorder: true, fontSize: 12, titleFontSize: 16 }); } /** * Get configuration preset * @param {string} presetName - Name of the preset * @returns {Object} Configuration preset or null */ getConfigPreset(presetName) { return this.configPresets.get(presetName) || null; } /** * Create custom configuration preset * @param {string} presetName - Name of the preset * @param {Object} config - Configuration object */ createConfigPreset(presetName, config) { this.configPresets.set(presetName, config); } /** * Get all available preset names * @returns {Array} Array of preset names */ getAvailablePresets() { return [...this.configPresets.keys()]; } } // Export for module systems // Make available globally for browser usage if (typeof window !== 'undefined') { window.ThemeManager = ThemeManager; } // === ChartFramework.js === /** * ChartFramework - Base chart class with SVG rendering and theme integration * Supports bar charts initially, extensible for other types */ class ChartFramework { constructor(container, config = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.containerId = Math.random().toString(36).substr(2, 9); // Generate unique ID for clipping paths this.data = []; this.themeManager = ThemeManager.getInstance(); this.config = this.mergeConfig(config); this.svg = null; this.tooltip = null; this.isDestroyed = false; this.lastUpdateTime = 0; // To prevent rapid resize loops // Tooltip timing control this.showTooltipTimer = null; this.hideTooltipTimer = null; this.currentDataPoint = null; this.tooltipFixed = false; // Track if tooltip is in fixed position mode this.lastHoveredElement = null; // Track which element we're hovering // Chart controls this.controlsContainer = null; this.controlsElement = null; // Register with theme manager this.themeManager.registerObserver(this); this.init(); } /** * Merge configuration with defaults * @param {Object} config - User configuration * @returns {Object} Merged configuration */ mergeConfig(config) { const defaults = { width: 600, height: 400, margin: { top: 40, right: 20, bottom: 50, left: 60 }, // Increased bottom margin to prevent text cutoff title: '', showGrid: true, showTooltip: true, responsive: true, animation: true, // Tooltip timing configuration tooltipShowDelay: 100, // Delay before showing tooltip (ms) tooltipHideDelay: 100, // Delay before hiding tooltip (ms) // Advanced tooltip formatting (Phase 3) tooltipTemplate: null, // Custom HTML template function tooltipFormatter: null, // Custom data formatter function tooltipStyle: 'default', // Style preset: 'default', 'compact', 'detailed', 'minimal' tooltipTheme: 'auto', // Theme: 'auto', 'light', 'dark', 'custom' tooltipPosition: 'auto', // Position: 'auto', 'top', 'bottom', 'left', 'right', 'follow' tooltipMaxWidth: 300, // Maximum tooltip width in pixels tooltipAnimation: 'fade', // Animation: 'fade', 'slide', 'scale', 'none' // Chart controls configuration showControls: false, // Show zoom/pan controls overlay controlsPosition: 'top-right', // Position: 'top-right', 'top-left', 'bottom-right', 'bottom-left' controlsStyle: 'compact', // Style: 'compact', 'full' // Export functionality enableExport: false, // Enable SVG/PNG export buttons exportFormats: ['svg', 'png'], // Available export formats exportFilename: 'chart', // Base filename for exports // DateTime and Localization (Phase 2) locale: 'auto', // Locale: 'auto', 'sv-SE', 'en-US', 'en-GB', 'de-DE', 'fr-FR' dateFormat: 'auto', // Format: 'auto', 'short', 'medium', 'long', 'full', 'custom' timeFormat: 'auto', // Time format: 'auto', 'short', 'medium', 'long' customDateFormat: null, // Custom date format function timezone: 'auto', // Timezone: 'auto', 'UTC', specific timezone // Advanced Data Labels (Phase 2) dataLabels: { enabled: false, // Enable data labels position: 'auto', // 'auto', 'top', 'bottom', 'center', 'inside', 'outside' alignment: 'center', // 'start', 'center', 'end' offset: { x: 0, y: -5 }, // Fine positioning control collision: 'auto', // 'auto', 'hide', 'truncate', 'rotate' minSpacing: 10, // Minimum space between labels showWhen: 'always', // 'always', 'hover', 'focus', 'large-values' minValue: null, // Only show labels for values above threshold maxLabels: null, // Maximum number of labels to show style: { fontSize: '11px', fontWeight: '500', color: 'auto', // 'auto', specific color, or function background: 'auto', // Background box for readability border: 'none', borderRadius: '3px', padding: '2px 4px', textShadow: 'none' }, formatter: null, // Custom formatter function conditionalStyle: null // Function for conditional styling }, // Enhanced Legend System (Phase 2) legend: { enabled: false, // Enable legend position: 'right', // 'top', 'bottom', 'left', 'right', 'inside' alignment: 'center', // 'start', 'center', 'end' orientation: 'auto', // 'horizontal', 'vertical', 'auto' layout: 'flow', // 'flow', 'grid', 'stack' columns: 'auto', // Number of columns for grid layout maxWidth: '25%', // Maximum legend width margin: { top: 10, right: 10, bottom: 10, left: 10 }, itemSpacing: 8, // Space between legend items symbolSpacing: 6, // Space between symbol and text style: { fontSize: '12px', fontFamily: 'inherit', color: 'auto', background: 'transparent', border: 'none', borderRadius: '4px', padding: '8px' }, symbol: { type: 'auto', // 'auto', 'square', 'circle', 'line', 'custom' size: 12, // Symbol size in pixels borderWidth: 1, // Symbol border borderColor: 'auto' }, // Interactive features clickable: true, // Enable click to toggle series hoverable: true, // Highlight on hover // Content customization showValues: false, // Show actual values in legend showPercentages: false, // Show percentages in legend itemTemplate: null, // Custom legend item template // Responsive behavior responsive: { mobile: { position: 'bottom', orientation: 'horizontal' }, tablet: { position: 'right', orientation: 'vertical' } } }, // Separate zoom sensitivities wheelZoomSensitivity: 0.15, // Mouse wheel zoom sensitivity (faster) buttonZoomSensitivity: 0.05 // Button zoom sensitivity (more precise) }; const merged = this.deepMerge(defaults, config); // Adjust top margin based on whether there's a title if (!merged.title || merged.title.trim() === '') { // No title: use smaller top margin for more compact layout if (!config.margin || config.margin.top === undefined) { merged.margin.top = 20; } } else { // Has title: ensure adequate spacing (18px above + font size + space below) if (!config.margin || config.margin.top === undefined) { const titleFontSize = merged.titleFontSize || 16; merged.margin.top = Math.max(55, 18 + titleFontSize + 20); // 18px above + font + 20px below } } return merged; } /** * Deep merge utility * @param {Object} target - Target object * @param {Object} source - Source object * @returns {Object} Merged object */ deepMerge(target, source) { const result = { ...target }; for (const key in source) { if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = this.deepMerge(result[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; } /** * Smart scaling algorithm that finds appropriate min/max values based on data range * Examples: * - Data range 0-6325 → Scale 0-7500 * - Data range 10.76-100.32 → Scale 10-110 * - Data range 1000-5000 → Scale 1000-5000 * - Data range 0-5 (integers) → Scale 0-5 with step 1 * * @param {number} dataMin - Minimum value in data * @param {number} dataMax - Maximum value in data * @param {Object} options - Scaling options * @param {Array} [originalData] - Original data array to check for integer-only data * @returns {Object} Object with min and max scaled values */ calculateSmartScale(dataMin, dataMax, options = {}, originalData = null) { const { includePadding = true, paddingPercentage = 0.1, forceZeroBaseline = true, preferRoundNumbers = true, minTicks = 5, maxTicks = 10 } = options; // Check if all data values are integers let isIntegerOnlyData = false; if (originalData && Array.isArray(originalData)) { isIntegerOnlyData = originalData.every(value => Number.isInteger(value)); } else { // Fallback: check if min and max are integers and range is small isIntegerOnlyData = Number.isInteger(dataMin) && Number.isInteger(dataMax) && (dataMax - dataMin) <= 20; } // Handle edge cases if (dataMin === dataMax) { const value = dataMin; const padding = Math.abs(value) * 0.1 || 1; return { min: value - padding, max: value + padding }; } // Calculate the data range let min = dataMin; let max = dataMax; // Force zero baseline for positive data if requested if (forceZeroBaseline && min >= 0) { min = 0; } const dataRange = max - min; let stepSize; // Special handling for integer-only data if (isIntegerOnlyData && dataRange <= 20) { // For small integer ranges, use step size of 1 stepSize = 1; } else if (isIntegerOnlyData && dataRange <= 100) { // For larger integer ranges, use appropriate integer steps (2, 5, 10, etc.) if (dataRange <= 20) stepSize = 1; else if (dataRange <= 50) stepSize = 5; else if (dataRange <= 100) stepSize = 10; else stepSize = Math.ceil(dataRange / 10); } else { // Enhanced algorithm for all decimal ranges - focus on the actual data range const magnitude = Math.floor(Math.log10(dataRange)); const normalizedRange = dataRange / Math.pow(10, magnitude); // Determine nice step size based on normalized range let niceStep; if (normalizedRange <= 1) { niceStep = 0.1; } else if (normalizedRange <= 2) { niceStep = 0.2; } else if (normalizedRange <= 5) { niceStep = 0.5; } else { niceStep = 1; } // Scale back to actual magnitude stepSize = niceStep * Math.pow(10, magnitude); } let scaleMin, scaleMax; if (preferRoundNumbers) { // Round to nice boundaries around the actual data range if (min >= 0 && forceZeroBaseline) { scaleMin = 0; } else { scaleMin = Math.floor(min / stepSize) * stepSize; } scaleMax = Math.ceil(max / stepSize) * stepSize; } else { scaleMin = min; scaleMax = max; } // Add padding if requested if (includePadding && !forceZeroBaseline) { const padding = (scaleMax - scaleMin) * paddingPercentage; scaleMin -= padding; scaleMax += padding; // Re-round after padding if preferRoundNumbers is true if (preferRoundNumbers) { scaleMin = Math.floor(scaleMin / stepSize) * stepSize; scaleMax = Math.ceil(scaleMax / stepSize) * stepSize; } } // Ensure we have a reasonable number of ticks const tickCount = Math.round((scaleMax - scaleMin) / stepSize); if (tickCount < minTicks) { // Increase range to get more ticks const targetRange = stepSize * minTicks; const extraRange = targetRange - (scaleMax - scaleMin); scaleMax += extraRange / 2; scaleMin -= extraRange / 2; // Keep zero baseline if it was originally there if (min >= 0 && forceZeroBaseline) { scaleMin = 0; scaleMax = targetRange; } } else if (tickCount > maxTicks) { // Use a larger step size const newStepSize = stepSize * Math.ceil(tickCount / maxTicks); if (min >= 0 && forceZeroBaseline) { scaleMin = 0; } else { scaleMin = Math.floor(min / newStepSize) * newStepSize; } scaleMax = Math.ceil(max / newStepSize) * newStepSize; } return { min: scaleMin, max: scaleMax, stepSize: stepSize, tickCount: Math.round((scaleMax - scaleMin) / stepSize), isIntegerScale: isIntegerOnlyData && stepSize >= 1 }; } /** * Smart number formatting that respects integer scales * @param {number} value - Value to format * @param {boolean} isIntegerScale - Whether this is an integer-only scale * @param {number} decimalPlaces - Default decimal places for non-integers * @returns {string} Formatted number */ formatScaleValue(value, isIntegerScale = false, decimalPlaces = 1) { if (isIntegerScale && Number.isInteger(value)) { return value.toString(); } // For non-integer scales, use appropriate decimal formatting if (Math.abs(value) < 1) { return value.toFixed(2); } else if (Math.abs(value) < 100) { return value.toFixed(decimalPlaces); } else { return Math.round(value).toString(); } } /** * Initialize chart */ init() { if (!this.container) { console.error('Container not found'); return; } this.createSVG(); this.createTooltip(); this.bindEvents(); if (this.config.responsive) { this.handleResize(); } } /** * Create SVG element */ createSVG() { const theme = this.themeManager.getCurrentTheme(); const dims = this.getDimensions(); this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svg.setAttribute('width', dims.width); this.svg.setAttribute('height', dims.height); this.svg.style.backgroundColor = theme.backgroundColor; this.svg.style.borderRadius = '4px'; this.container.appendChild(this.svg); } /** * Clear existing chart elements */ clearChart() { if (this.svg) { this.svg.innerHTML = ''; } } /** * Render chart title */ renderTitle() { if (!this.config.title) return; const theme = this.themeManager.getCurrentTheme(); const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); // Center title horizontally title.setAttribute('x', this.config.width / 2); // Position title with guaranteed spacing above and below const titleFontSize = this.config.titleFontSize || this.themeManager.getThemeChartConfig().titleFontSize || 16; // Always maintain 18px space above title, position title in upper portion of margin const spaceAboveTitle = 18; const titleY = spaceAboveTitle + titleFontSize; title.setAttribute('y', titleY); title.setAttribute('text-anchor', 'middle'); title.setAttribute('fill', theme.textColor); title.setAttribute('font-size', titleFontSize); title.setAttribute('font-weight', 'bold'); title.textContent = this.config.title; title.setAttribute('class', 'chart-title'); this.svg.appendChild(title); } /** * Create tooltip element */ createTooltip() { if (!this.config.showTooltip) return; this.tooltip = document.createElement('div'); this.tooltip.className = 'tapir-tooltip'; // Enhanced tooltip styling with GMF variables const theme = this.themeManager.getCurrentTheme(); this.tooltip.style.cssText = ` position: fixed; display: none; background: ${theme.tooltipBackground}; color: ${theme.tooltipText}; border: 1px solid ${theme.tooltipBorder}; padding: var(--space-s, 12px) var(--space-m, 16px); border-radius: var(--radius-s, 8px); font-size: var(--step-0, 13px); font-weight: 500; font-family: Inter, system-ui, sans-serif; pointer-events: none; z-index: 1000; box-shadow: var(--shadow-m, 0 4px 20px rgba(0,0,0,0.15)); backdrop-filter: blur(8px); max-width: 300px; line-height: 1.4; left: 0px; top: 0px; white-space: nowrap; `; document.body.appendChild(this.tooltip); } /** * Bind chart events */ bindEvents() { if (this.config.showTooltip) { this.svg.addEventListener('mousemove', this.handleMouseMove.bind(this)); this.svg.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); } } /** * Handle mouse movement for tooltip * @param {MouseEvent} event - Mouse event */ handleMouseMove(event) { if (!this.config.showTooltip || !this.tooltip) return; // Throttle tooltip updates for better performance if (this.tooltipUpdateTimer) { cancelAnimationFrame(this.tooltipUpdateTimer); } this.tooltipUpdateTimer = requestAnimationFrame(() => { const rect = this.svg.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; const dataPoint = this.getDataPointAtPosition(x, y); const elementId = dataPoint ? this.getDataPointId(dataPoint) : null; // Check if we've entered a new element or exited current one if (elementId !== this.lastHoveredElement) { if (this.lastHoveredElement !== null) { // We've exited the previous element this.scheduleTooltipHide(); } if (elementId !== null) { // We've entered a new element this.scheduleTooltipShow(dataPoint, event.clientX, event.clientY); } this.lastHoveredElement = elementId; } // If we're still on the same element, do nothing (tooltip stays fixed) }); } /** * Handle mouse leave event * @param {MouseEvent} event - Mouse event */ handleMouseLeave(event) { // Immediately hide tooltip when mouse leaves the chart area this.hideTooltipImmediate(); // Clear any pending timers if (this.showTooltipTimer) { clearTimeout(this.showTooltipTimer); this.showTooltipTimer = null; } if (this.hideTooltipTimer) { clearTimeout(this.hideTooltipTimer); this.hideTooltipTimer = null; } // Reset all state this.currentDataPoint = null; this.tooltipFixed = false; this.lastHoveredElement = null; } /** * Get data point at mouse position * @param {number} x - X coordinate * @param {number} y - Y coordinate * @returns {Object|null} Data point or null */ getDataPointAtPosition(x, y) { // This will be implemented by specific chart types return null; } /** * Get unique identifier for a data point * @param {Object} dataPoint - Data point object * @returns {string} Unique identifier */ getDataPointId(dataPoint) { // Create a unique ID based on data point properties if (dataPoint.id) return dataPoint.id; if (dataPoint.x !== undefined && dataPoint.y !== undefined) { return `${dataPoint.x}-${dataPoint.y}`; } if (dataPoint.event && dataPoint.event.id) { return dataPoint.event.id; } // Fallback: hash based on all properties return JSON.stringify(dataPoint); } /** * Schedule tooltip show with delay * @param {Object} dataPoint - Data point to display * @param {number} x - X coordinate * @param {number} y - Y coordinate */ scheduleTooltipShow(dataPoint, x, y) { // Clear any pending hide timer if (this.hideTooltipTimer) { clearTimeout(this.hideTooltipTimer); this.hideTooltipTimer = null; } // If tooltip is already showing the same data point, don't move it if (this.currentDataPoint && this.getDataPointId(this.currentDataPoint) === this.getDataPointId(dataPoint) && this.tooltip.style.display === 'block') { return; // Keep tooltip in same position } // Clear any pending show timer if (this.showTooltipTimer) { clearTimeout(this.showTooltipTimer); } // Schedule new show with delay this.showTooltipTimer = setTimeout(() => { this.showTooltipImmediate(dataPoint, x, y); this.tooltipFixed = true; // Mark tooltip as fixed position this.showTooltipTimer = null; }, this.config.tooltipShowDelay); // Store the data point for comparison this.currentDataPoint = dataPoint; } /** * Schedule tooltip hide with delay */ scheduleTooltipHide() { // Clear any pending show timer if (this.showTooltipTimer) { clearTimeout(this.showTooltipTimer); this.showTooltipTimer = null; } // Clear any pending hide timer if (this.hideTooltipTimer) { clearTimeout(this.hideTooltipTimer); } // Schedule hide with delay this.hideTooltipTimer = setTimeout(() => { this.hideTooltipImmediate(); this.hideTooltipTimer = null; this.currentDataPoint = null; this.tooltipFixed = false; // Reset fixed state }, this.config.tooltipHideDelay); } /** * Show tooltip (public method with delay) * @param {Object} dataPoint - Data point to display * @param {number} x - X coordinate * @param {number} y - Y coordinate */ showTooltip(dataPoint, x, y) { this.scheduleTooltipShow(dataPoint, x, y); } /** * Show tooltip immediately (internal method) * @param {Object} dataPoint - Data point to display * @param {number} x - X coordinate * @param {number} y - Y coordinate */ showTooltipImmediate(dataPoint, x, y) { if (!this.tooltip) return; const theme = this.themeManager.getCurrentTheme(); this.tooltip.style.background = theme.tooltipBackground; this.tooltip.style.color = theme.tooltipText; this.tooltip.style.border = `1px solid ${theme.tooltipBorder}`; // Generate tooltip content using custom formatting system const tooltipContent = this.generateTooltipContent(dataPoint, theme); this.tooltip.innerHTML = tooltipContent; // Smooth tooltip positioning with animation this.updateTooltipPosition(x, y); } /** * Update tooltip position smoothly * @param {number} x - X coordinate * @param {number} y - Y coordinate */ updateTooltipPosition(x, y) { if (!this.tooltip) return; // Show tooltip temporarily to get dimensions this.tooltip.style.display = 'block'; this.tooltip.style.visibility = 'hidden'; // Hidden but taking space for measurement this.tooltip.style.left = '0px'; this.tooltip.style.top = '0px'; // Get tooltip dimensions const tooltipRect = this.tooltip.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let left = x + 15; let top = y - 15; // Adjust horizontal position if (left + tooltipRect.width > viewportWidth - 10) { left = x - tooltipRect.width - 15; } // Adjust vertical position if (top + tooltipRect.height > viewportHeight - 10) { top = y - tooltipRect.height - 15; } // Ensure tooltip doesn't go off-screen left = Math.max(10, Math.min(left, viewportWidth - tooltipRect.width - 10)); top = Math.max(10, Math.min(top, viewportHeight - tooltipRect.height - 10)); // Handle transitions intelligently const currentLeft = parseInt(this.tooltip.style.left) || 0; const currentTop = parseInt(this.tooltip.style.top) || 0; const deltaX = Math.abs(left - currentLeft); const deltaY = Math.abs(top - currentTop); // Only animate if tooltip is already visible and the movement is small const isTooltipVisible = this.tooltip.style.visibility === 'visible' && this.tooltip.style.display === 'block'; if (isTooltipVisible && deltaX < 50 && deltaY < 50) { // Small movement on visible tooltip - smooth transition this.tooltip.style.transition = 'left 0.1s ease, top 0.1s ease'; } else { // First show or large jump - no transition this.tooltip.style.transition = 'none'; } // Apply final position and make visible this.tooltip.style.left = left + 'px'; this.tooltip.style.top = top + 'px'; this.tooltip.style.visibility = 'visible'; // Add subtle fade-in for first appearance if (!isTooltipVisible) { this.tooltip.style.opacity = '0'; // Force a reflow to ensure opacity is set before transition this.tooltip.offsetHeight; this.tooltip.style.transition = 'opacity 0.15s ease'; this.tooltip.style.opacity = '1'; } else { this.tooltip.style.opacity = '1'; } // Force styles to override any theme issues - use theme variables const currentTheme = this.themeManager.getCurrentTheme(); if (!this.tooltip.style.backgroundColor || this.tooltip.style.backgroundColor === 'transparent') { this.tooltip.style.backgroundColor = currentTheme.tooltipBackground; } if (!this.tooltip.style.color || this.tooltip.style.color === 'transparent') { this.tooltip.style.color = currentTheme.tooltipText; } } /** * Format numeric value with proper decimal precision * @param {*} value - Value to format * @param {number} maxDecimals - Maximum decimal places (default: 2) * @returns {string} Formatted value */ formatNumber(value, maxDecimals = 2) { if (typeof value !== 'number' || isNaN(value)) { return value; } // Round to avoid floating-point precision issues const multiplier = Math.pow(10, maxDecimals); const rounded = Math.round(value * multiplier) / multiplier; // Format with appropriate decimal places if (Math.abs(rounded) >= 1000) { // For large numbers, use locale formatting with limited decimals return rounded.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: Math.min(maxDecimals, 1) }); } else if (Math.abs(rounded) < 0.01 && rounded !== 0) { // For very small numbers, use scientific notation return rounded.toExponential(1); } else { // For normal numbers, show up to maxDecimals places, removing trailing zeros return parseFloat(rounded.toFixed(maxDecimals)).toString(); } } /** * Format value for tooltip display * @param {number} value - Value to format * @returns {string} Formatted value */ formatTooltipValue(value) { if (this.config.valueFormat && typeof this.config.valueFormat === 'function') { return this.config.valueFormat(value); } // Use improved number formatting return this.formatNumber(value, 2); } /** * Generate tooltip content using custom formatting system (Phase 3) * @param {Object} dataPoint - Data point to display * @param {Object} theme - Current theme object * @returns {string} HTML content for tooltip */ generateTooltipContent(dataPoint, theme) { // Check for custom template first if (this.config.tooltipTemplate && typeof this.config.tooltipTemplate === 'function') { try { return this.config.tooltipTemplate(dataPoint, theme, this); } catch (error) { console.warn('Custom tooltip template failed, falling back to default:', error); } } // Check for custom formatter if (this.config.tooltipFormatter && typeof this.config.tooltipFormatter === 'function') { try { const formattedData = this.config.tooltipFormatter(dataPoint, this); return this.renderTooltipTemplate(formattedData, theme); } catch (error) { console.warn('Custom tooltip formatter failed, falling back to default:', error); } } // Use style-based templates switch (this.config.tooltipStyle) { case 'compact': return this.generateCompactTooltip(dataPoint, theme); case 'detailed': return this.generateDetailedTooltip(dataPoint, theme); case 'minimal': return this.generateMinimalTooltip(dataPoint, theme); default: return this.generateDefaultTooltip(dataPoint, theme); } } /** * Generate default tooltip content */ generateDefaultTooltip(dataPoint, theme) { // Handle extended tooltip for line charts if (dataPoint.extended && dataPoint.dataPoints) { return this.generateExtendedTooltip(dataPoint, theme); } let content = ` <div style="font-weight: 600; margin-bottom: var(--space-xs, 8px); font-size: var(--step-0, 14px); color: ${theme.tooltipText};">${dataPoint.x}</div> `; // Handle stacked data display if (dataPoint.segments && dataPoint.segmentLabel) { content += this.renderStackedTooltipContent(dataPoint, theme); } else { content += this.renderSimpleTooltipContent(dataPoint, theme); } // Add comparison data if available if (dataPoint.previousValue !== undefined) { content += this.renderComparisonContent(dataPoint, theme); } // Add custom tooltip content if provided if (dataPoint.tooltip) { content += ` <div style="padding-top: 6px; margin-top: 6px; border-top: 1px solid ${theme.tooltipBorder}; font-size: 12px; opacity: 0.9;"> ${dataPoint.tooltip} </div> `; } return content; } /** * Generate extended tooltip content for line charts */ generateExtendedTooltip(dataPoint, theme) { let content = ` <div style="font-weight: