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
JavaScript
/**
 * 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: