UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

1,255 lines (1,252 loc) 44 kB
'use client'; import { jsx, jsxs } from 'react/jsx-runtime'; import React, { forwardRef, useRef, useState, useEffect, useMemo, useCallback, memo } from 'react'; import { createGlassStyle } from '../../core/mixins/glassMixins.js'; import { cn } from '../../lib/utilsComprehensive.js'; import chartStyles from './GlassDataChart.module.css.js'; import { Chart, CategoryScale, LinearScale, PointElement, LineElement, BarElement, ArcElement, RadialLinearScale, Tooltip, Legend, Filler, defaults } from 'chart.js'; import { Chart as Chart$1 } from 'react-chartjs-2'; import { useAccessibilitySettings } from '../../hooks/useAccessibilitySettings.js'; import { GlassTooltip } from '../modal/GlassTooltip.js'; import { useQualityTier, getQualityBasedPhysicsParams, getQualityBasedGlassParams } from './hooks/useQualityTier.js'; import { glassTokenUtils } from '../../tokens/glass.js'; const usePhysicsAnimation = options => ({ value: 1, applyOscillation: intensity => {}, applyPopIn: () => {} }); const useChartPhysicsInteraction = (chartRef, wrapperRef, options) => ({ isPanning: false, zoomLevel: 1, applyZoom: level => {}, resetZoom: () => {} }); const GalileoElementInteractionPlugin = { id: 'galileoElementInteraction' }; // Dataset conversion to prevent unnecessary recalculations const convertToChartJsDatasetWithEffects = (dataset, index, chartType, palette, animation) => { const paletteColor = palette[index % (palette?.length || 0)]; return { ...dataset, backgroundColor: dataset.style?.fillColor || paletteColor + '40', borderColor: dataset.style?.lineColor || paletteColor, borderWidth: dataset.style?.borderWidth || 2, pointBackgroundColor: dataset.style?.pointColor || paletteColor, pointBorderColor: dataset.style?.pointColor || paletteColor, pointRadius: dataset.style?.pointRadius || 4, tension: dataset.style?.tension || 0.4, fill: chartType === 'area' }; }; // Simple format function const formatValue = value => String(value); const getContainerBackground = (variant, color) => { switch (variant) { case 'clear': return 'transparent'; case 'dynamic': return 'color-mix(in srgb, var(--aura-color-glass-overlay) 65%, rgba(12, 18, 32, 0.45))'; case 'tinted': return color ? `color-mix(in srgb, ${color} 18%, rgba(12, 18, 32, 0.65))` : 'rgba(99, 102, 241, 0.14)'; case 'luminous': return 'color-mix(in srgb, var(--aura-color-semantic-primary) 12%, rgba(255, 255, 255, 0.08))'; default: return 'color-mix(in srgb, var(--aura-color-glass-surface) 92%, transparent)'; } }; const getBlurStrength = value => { switch (value) { case 'none': return 'none'; case 'light': return 'blur(var(--aura-glass-neutral-level1-backdrop-blur))'; case 'heavy': return 'blur(var(--aura-glass-neutral-level3-backdrop-blur))'; default: return 'blur(var(--aura-glass-neutral-level2-backdrop-blur))'; } }; const getElevationShadow = level => { switch (level) { case 0: return 'none'; case 1: return '0 12px 28px rgba(15, 23, 42, 0.18)'; case 2: return '0 18px 48px rgba(15, 23, 42, 0.24)'; case 3: return '0 26px 60px rgba(15, 23, 42, 0.28)'; case 4: return '0 32px 80px rgba(15, 23, 42, 0.32)'; default: return '0 18px 48px rgba(15, 23, 42, 0.24)'; } }; const ChartContainer = /*#__PURE__*/forwardRef((props, ref) => { const { $glassVariant = 'frosted', $blurStrength = 'standard', $color, $elevation = 2, $borderRadius = 12, $borderColor, className, style, children, ...rest } = props; const background = getContainerBackground($glassVariant, $color); const blur = getBlurStrength($blurStrength); const boxShadow = getElevationShadow($elevation); return jsx("div", { ref: ref, className: cn(chartStyles.container, className), style: { padding: '20px', borderRadius: typeof $borderRadius === 'number' ? `${$borderRadius}px` : $borderRadius ?? '12px', background, backdropFilter: blur, WebkitBackdropFilter: blur, border: `1px solid ${$borderColor || 'color-mix(in srgb, var(--aura-color-global-border-soft) 75%, transparent)'}`, boxShadow, ...style }, ...rest, children: children }); }); ChartContainer.displayName = 'ChartContainer'; const ChartHeader = ({ className, ...props }) => jsx("div", { className: cn(chartStyles.header, className), ...props }); const ChartTitle = ({ className, ...props }) => jsx("h3", { className: cn(chartStyles.title, className), ...props }); const ChartSubtitle = ({ className, ...props }) => jsx("p", { className: cn(chartStyles.subtitle, className), ...props }); const ChartWrapper = /*#__PURE__*/forwardRef(({ className, ...props }, ref) => jsx("div", { ref: ref, className: cn(chartStyles.wrapper, className), ...props })); ChartWrapper.displayName = 'ChartWrapper'; const ChartLegend = /*#__PURE__*/forwardRef(({ $position, $style: legendStyleVariant, $glassEffect, className, style, ...props }, ref) => jsx("div", { ref: ref, className: cn(chartStyles.legend, $position === 'top' && chartStyles.legendTop, $position === 'bottom' && chartStyles.legendBottom, $position === 'left' && chartStyles.legendLeft, $position === 'right' && chartStyles.legendRight, $glassEffect && chartStyles.legendGlass, className), "data-legend-style": legendStyleVariant, style: style, ...props })); ChartLegend.displayName = 'ChartLegend'; const LegendItem = ({ $style: legendStyleVariant, $color, $active = true, className, style, ...props }) => jsx("div", { className: cn(chartStyles.legendItem, !$active && chartStyles.legendItemInactive, className), "data-legend-style": legendStyleVariant, style: { '--legend-item-color': $color, ...style }, ...props }); const LegendColor = ({ $color, $active = true, className, style, ...props }) => jsx("div", { className: cn(chartStyles.legendColor, className), style: { backgroundColor: $color || '#6366f1', opacity: $active ? 1 : 0.3, ...style }, ...props }); const LegendLabel = ({ $active = true, className, style, ...props }) => jsx("span", { className: cn(chartStyles.legendLabel, className), style: { opacity: $active ? 1 : 0.6, ...style }, ...props }); const DynamicTooltip = ({ $color, className, style, ...props }) => jsx("div", { className: cn(chartStyles.tooltip, className), style: { borderColor: $color || undefined, boxShadow: $color ? `0 20px 48px color-mix(in srgb, ${$color} 35%, rgba(15, 23, 42, 0.32))` : undefined, ...style }, ...props }); const TooltipHeader = ({ $color, className, style, ...props }) => jsx("div", { className: cn(chartStyles.tooltipHeader, className), style: { color: $color || undefined, ...style }, ...props }); const TooltipRow = ({ className, ...props }) => jsx("div", { className: cn(chartStyles.tooltipRow, className), ...props }); const TooltipLabel = ({ className, style, ...props }) => jsx("span", { className: cn(chartStyles.tooltipLabel, className), style: style, ...props }); const TooltipValue = ({ $highlighted, className, style, ...props }) => jsx("span", { className: cn(chartStyles.tooltipValue, className), style: { color: $highlighted ? 'var(--aura-color-global-text-inverse)' : glassTokenUtils.getSurface('neutral', 'level1').text.primary, ...style }, ...props }); // Register required Chart.js components Chart.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, ArcElement, RadialLinearScale, Tooltip, Legend, Filler); // Custom SVG path animation plugin const pathAnimationPlugin = { id: 'pathAnimation', afterDraw: chart => { chart.data?.datasets.forEach((dataset, datasetIndex) => { const meta = chart.getDatasetMeta(datasetIndex); if (meta.type === 'line' && meta.dataset) { const element = meta.dataset; // Keep 'as any': _path is likely internal/non-standard for path animation if (element && element._path) { const path = element._path; // Check if we already processed this path if (!path._animationApplied && path.getTotalLength) { try { // Mark as processed to avoid reapplying path._animationApplied = true; // Get path length for animation const pathLength = path.getTotalLength(); // Apply stroke dash settings path.style.strokeDasharray = `${pathLength} ${pathLength}`; path.style.strokeDashoffset = `${pathLength}`; // Create animation with WAAPI path.animate && path.animate([{ strokeDashoffset: pathLength }, { strokeDashoffset: 0 }], { duration: 1500, delay: datasetIndex * 150, fill: 'forwards', easing: 'ease-out' }); } catch (err) { // Fallback for browsers that don't support these features if (process.env.NODE_ENV === 'development') { console.log('Advanced path animation not supported in this browser'); } } } } } }); } }; // Register the custom plugin Chart.register(pathAnimationPlugin); // Register our interaction plugin Chart.register(GalileoElementInteractionPlugin); // Adjust Chart.js defaults safely if (defaults?.plugins?.tooltip) { defaults.plugins.tooltip.enabled = false; // Use custom tooltip } if (defaults?.font) { defaults.font.family = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; } // Set colors safely defaults.color = '${glassStyles.text?.secondary || "rgba(var(--glass-color-white) / var(--glass-opacity-70))"}'; defaults.borderColor = '${glassStyles.surface?.base || "var(--glass-bg-default)"}'; /** * Register required Chart.js components */ Chart.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, ArcElement, RadialLinearScale, Tooltip, Legend, Filler // Required for area charts ); // Memoized icon components to prevent unnecessary re-renders const ZoomInIcon = /*#__PURE__*/memo(({ size = 24 }) => jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", xmlns: "http://www.w3.org/2000/svg", children: jsx("path", { d: "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" }) })); const ZoomOutIcon = /*#__PURE__*/memo(({ size = 24 }) => jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", xmlns: "http://www.w3.org/2000/svg", children: jsx("path", { d: "M19 13H5v-2h14v2z" }) })); const RefreshIcon = /*#__PURE__*/memo(({ size = 24 }) => jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "currentColor", xmlns: "http://www.w3.org/2000/svg", children: jsx("path", { d: "M17.65 6.35C16.2 4.9 14.2 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 10h7V3l-2.35 3.35z" }) })); // Memoized button component with stable styles const buttonStyles = { base: createGlassStyle({ intent: "neutral", elevation: "level2" }), sm: { padding: '4px' }, default: { padding: '8px' } }; const GlassButton = /*#__PURE__*/memo(({ children, variant, size, onClick, glass, 'aria-label': ariaLabel }) => { const combinedStyle = useMemo(() => ({ ...buttonStyles.base, ...(size === 'sm' ? buttonStyles.sm : buttonStyles.default) }), [size]); return jsx("button", { onClick: onClick, "aria-label": ariaLabel, style: combinedStyle, className: "glass-focus glass-touch-target glass-contrast-guard glass-focus glass-touch-target glass-contrast-guard", children: children }); }); // Stable styles for zoom controls container const zoomControlsStyle = createGlassStyle({ intent: "neutral", elevation: "level2" }); const zoomLevelStyle = { color: '${glassStyles.text?.primary || "rgba(var(--glass-color-white) / var(--glass-opacity-90))"}', fontSize: 'var(--typography-caption-size)', padding: '0 8px', minWidth: '40px', textAlign: 'center' }; const ZoomControls = /*#__PURE__*/memo(({ onZoomIn, onZoomOut, onReset, zoomLevel, $variant = 'frosted' }) => { const displayZoom = useMemo(() => Math.round(zoomLevel * 100), [zoomLevel]); return jsxs("div", { style: zoomControlsStyle, children: [jsx(GlassButton, { variant: "icon", size: "sm", onClick: onZoomIn, "aria-label": "Zoom in", glass: $variant, children: jsx(ZoomInIcon, { size: 16 }) }), jsxs("span", { style: zoomLevelStyle, children: [displayZoom, "%"] }), jsx(GlassButton, { variant: "icon", size: "sm", onClick: onZoomOut, "aria-label": "Zoom out", glass: $variant, children: jsx(ZoomOutIcon, { size: 16 }) }), jsx(GlassButton, { variant: "icon", size: "sm", onClick: onReset, "aria-label": "Reset zoom", glass: $variant, children: jsx(RefreshIcon, { size: 16 }) })] }); }); /** * GlassDataChart Component */ // Memoized main component to prevent unnecessary re-renders const GlassDataChartComponent = /*#__PURE__*/React.forwardRef((props, ref) => { const { title, subtitle, variant = 'line', datasets, width = '100%', height = 400, glassVariant = 'frosted', blurStrength = 'standard', color = 'primary', animation = { physicsEnabled: true, duration: 1000, tension: 300, friction: 30, mass: 1, easing: 'easeOutQuart', staggerDelay: 100 }, interaction = { zoomPanEnabled: false, zoomMode: 'xy', // Ensure default matches type ('x' | 'y' | 'xy') physicsHoverEffects: true, hoverSpeed: 150, showTooltips: true, tooltipStyle: 'frosted', tooltipFollowCursor: false, // Add the physics sub-object with defaults to match the updated type physics: { tension: 300, // Default tension for zoom/pan physics friction: 30, // Default friction mass: 1, minZoom: 0.5, maxZoom: 5, wheelSensitivity: 0.1, inertiaDuration: 500 } }, legend = { show: true, position: 'top', align: 'center', style: 'default', glassEffect: false }, axis = { showXGrid: true, showYGrid: true, showXLabels: true, showYLabels: true, axisColor: '${glassStyles.borderColor || "var(--glass-bg-hover)"}', gridColor: '${glassStyles.surface?.base || "var(--glass-bg-default)"}', gridStyle: 'solid' }, initialSelection, showToolbar = true, allowDownload = true, palette = ['#6366F1', // primary '#8B5CF6', // secondary 'var(--glass-color-primary)', // blue 'var(--glass-color-success)', // green 'var(--glass-color-warning)', // yellow 'var(--glass-color-danger)', // red '#EC4899', // pink 'var(--glass-gray-500)' // gray ], allowTypeSwitch = true, borderRadius = 12, borderColor, elevation = 'level2', className, style, onDataPointClick, onSelectionChange, onTypeChange, onZoomPan, exportOptions = { filename: 'chart', quality: 0.9, format: 'png', backgroundColor: 'transparent', includeTitle: true, includeTimestamp: true }, renderExportButton, kpi, useAdaptiveQuality = true, getElementPhysicsOptions, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...restProps } = props; const chartAriaLabel = ariaLabel || (typeof title === 'string' && title.trim().length > 0 ? title : undefined) || 'Data visualization'; // Hooks // const theme = useGlassTheme(); // unused const { settings: accessibilitySettings } = useAccessibilitySettings(); const isReducedMotion = accessibilitySettings?.reducedMotion || false; const chartRef = useRef(null); const containerRef = useRef(null); const chartWrapperRef = useRef(null); // Quality tier system integration const qualityTier = useQualityTier({ dataPointCount: datasets?.reduce((sum, dataset) => sum + (dataset.data?.length || 0), 0) || 0, seriesCount: datasets?.length || 0, animationComplexity: 'medium', interactionComplexity: 'medium' }, variant, useAdaptiveQuality ? undefined : 'high'); const activeQuality = useAdaptiveQuality ? qualityTier : 'high'; // Get physics parameters based on quality tier const qualityPhysicsParams = getQualityBasedPhysicsParams(activeQuality); const qualityGlassParams = getQualityBasedGlassParams(activeQuality); // Adapt quality based on user's settings const adaptedBlurStrength = qualityGlassParams.blurStrength || blurStrength; // Physics animation for main chart const { value: springValue, applyOscillation, applyPopIn } = usePhysicsAnimation({ type: isReducedMotion ? 'none' : animation.physicsEnabled ? 'spring' : 'none', stiffness: qualityPhysicsParams.stiffness, damping: qualityPhysicsParams.dampingRatio * 2 * Math.sqrt(qualityPhysicsParams.stiffness * qualityPhysicsParams.mass), mass: qualityPhysicsParams.mass, precision: qualityPhysicsParams.precision, adaptiveMotion: true, respectReducedMotion: true }); // Use our physics interaction hook for zoom/pan functionality const { isPanning, zoomLevel, applyZoom, resetZoom } = useChartPhysicsInteraction(chartRef, chartWrapperRef, { enabled: interaction.zoomPanEnabled || false, mode: interaction.zoomMode || 'xy', physics: { tension: interaction.physics?.tension || qualityPhysicsParams.stiffness, friction: interaction.physics?.friction || qualityPhysicsParams.dampingRatio * 2 * Math.sqrt(qualityPhysicsParams.stiffness * qualityPhysicsParams.mass), mass: interaction.physics?.mass || qualityPhysicsParams.mass }, minZoom: interaction.physics?.minZoom || 0.5, maxZoom: interaction.physics?.maxZoom || 5, wheelSensitivity: interaction.physics?.wheelSensitivity || 0.1, inertiaDuration: interaction.physics?.inertiaDuration || 500, respectReducedMotion: true }); // State const [chartType, setChartType] = useState(variant); const [selectedDataset, setSelectedDataset] = useState(typeof initialSelection === 'number' ? initialSelection : null); const [selectedDatasets, setSelectedDatasets] = useState(Array.isArray(initialSelection) ? initialSelection : []); const [hoveredPoint, setHoveredPoint] = useState(null); // Internal element animation state (managed by React) // The plugin will read targets from this or similar structure const [elementAnimationTargets, setElementAnimationTargets] = useState(new Map()); // Key: `datasetIndex_dataIndex`, Value: { targetScale: 1, targetOpacity: 1, ... } // Determine if we're using physics-based animations const enablePhysicsAnimation = animation.physicsEnabled && !isReducedMotion; // Apply initial animations based on quality tier useEffect(() => { if (enablePhysicsAnimation) { // Trigger a pop-in animation on mount for better visual impact if (activeQuality !== 'low') { applyPopIn(); } } }, [enablePhysicsAnimation, activeQuality, applyPopIn]); // Derive chartjs type from our variant const getChartJsType = () => { // Special handling for KPI type (we'll render our own component) if (chartType === 'kpi') { return 'bar'; // Just a placeholder, we won't render the chart } // Map area to line type since it's not a native Chart.js type if (chartType === 'area') { // Use direct assignment for the most compatibility return 'line'; } // For all other chart types return chartType; }; // Memoized SVG Filter Definitions to prevent expensive re-renders const svgFilters = useMemo(() => jsx("svg", { width: "0", height: "0", style: { position: 'absolute', visibility: 'hidden' }, children: jsx("defs", { children: palette.map((color, i) => { // Ensure color is a valid value to prevent SVG errors const safeColor = color || '#6366F1'; // Default to primary color if undefined return jsxs(React.Fragment, { children: [jsxs("linearGradient", { id: `areaGradient${i}`, x1: "0%", y1: "0%", x2: "0%", y2: "100%", children: [jsx("stop", { offset: "0%", stopColor: `${safeColor}CC` }), jsx("stop", { offset: "100%", stopColor: `${safeColor}00` })] }), jsxs("filter", { id: `glow${i}`, x: "-20%", y: "-20%", width: "140%", height: "140%", children: [jsx("feGaussianBlur", { stdDeviation: activeQuality === 'low' ? 1 : 2, result: "blur" }), jsx("feComposite", { in: "SourceGraphic", in2: "blur", operator: "over" })] }), jsxs("filter", { id: `pointGlow${i}`, x: "-50%", y: "-50%", width: "200%", height: "200%", children: [jsx("feGaussianBlur", { stdDeviation: activeQuality === 'low' ? 2 : 3, result: "blur" }), jsx("feComposite", { in: "SourceGraphic", in2: "blur", operator: "over" })] })] }, `gradient-${i}`); }) }) }), [palette, activeQuality]); // Memoize the converted datasets to prevent unnecessary recalculations const convertedDatasets = useMemo(() => { if (!datasets) return []; return datasets.map((dataset, i) => { return convertToChartJsDatasetWithEffects(dataset, i, chartType, palette); }); }, [datasets, chartType, palette, animation]); // Memoize chart labels preparation const chartLabels = useMemo(() => { let labels; if (chartType === 'pie' || chartType === 'doughnut') { // Access processedLabels from the *first* dataset's conversion result const firstConvertedDataset = convertedDatasets?.[0]; if (firstConvertedDataset?.processedLabels && (firstConvertedDataset.processedLabels?.length || 0) > 0) { labels = firstConvertedDataset.processedLabels; } else if (datasets && datasets[0]?.data) { // Fallback to original data labels if processed labels aren't available labels = datasets[0].data?.map(point => point.label || String(point.x)); } } else if (chartType === 'polarArea' && datasets) { // Use original labels for polarArea labels = datasets[0]?.data?.map(point => point.label || String(point.x)) || []; } return labels; }, [chartType, convertedDatasets, datasets]); // Memoize chart data to prevent unnecessary Chart.js updates const chartData = useMemo(() => ({ // Map converted datasets, removing any temporary properties like processedLabels datasets: convertedDatasets.map(ds => { const { processedLabels, ...rest } = ds; // Use type assertion here too return rest; }), labels: chartLabels // Assign the prepared labels }), [convertedDatasets, chartLabels]); // Zoom in function const handleZoomIn = useCallback(() => { applyZoom(zoomLevel * 1.2); }, [applyZoom, zoomLevel]); // Zoom out function const handleZoomOut = useCallback(() => { applyZoom(zoomLevel * 0.8); }, [applyZoom, zoomLevel]); // Handle zoom changed callback useCallback(() => { if (onZoomPan && chartRef.current) { onZoomPan(chartRef.current); } }, [onZoomPan]); // Memoized hex to RGB conversion function const hexToRgb = useCallback(hex => { // Provide a default color if hex is undefined const safeHex = hex || 'var(--glass-white)'; const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(safeHex); return result ? `${parseInt(result?.[1], 16)}, ${parseInt(result?.[2], 16)}, ${parseInt(result?.[3], 16)}` : '255, 255, 255'; }, []); // Memoized legend items to prevent unnecessary re-renders const legendItems = useMemo(() => { if (!datasets) return []; return datasets.map((dataset, index) => { const color = dataset.style?.lineColor || palette[index % (palette?.length || 0)]; const rgbColor = hexToRgb(color); const isActive = !selectedDatasets.includes(index); return { id: dataset.id, label: dataset.label, color: color || 'var(--glass-white)', rgbColor, isActive, index }; }); }, [datasets, palette, selectedDatasets, hexToRgb]); // Optimized legend item click handler const handleLegendClick = useCallback(index => { if (chartType === 'pie' || chartType === 'doughnut' || chartType === 'polarArea') { // For pie charts, handle single selection setSelectedDataset(selectedDataset === index ? null : index); if (onSelectionChange) { onSelectionChange(selectedDataset === index ? [] : [index]); } } else { // For other charts, handle multi-selection let newSelectedDatasets = [...selectedDatasets]; if (newSelectedDatasets.includes(index)) { newSelectedDatasets = newSelectedDatasets.filter(i => i !== index); } else { newSelectedDatasets.push(index); } setSelectedDatasets(newSelectedDatasets); if (onSelectionChange) { onSelectionChange(newSelectedDatasets); } } // Update the visible datasets if (chartRef.current) { const chart = chartRef.current; // Toggle dataset visibility chart.setDatasetVisibility(index, !chart.isDatasetVisible(index)); chart.update(); } }, [chartType, selectedDataset, selectedDatasets, onSelectionChange]); // Memoized legend renderer to avoid duplication const renderLegend = useCallback(() => jsx(ChartLegend, { "$position": legend.position, "$style": legend.style || 'default', "$glassEffect": legend.glassEffect || false, children: legendItems.map(item => jsxs(LegendItem, { "$style": legend.style || 'default', "$active": item.isActive, "$color": item.rgbColor, onClick: e => handleLegendClick(item.index), children: [jsx(LegendColor, { "$color": item.color, "$active": item.isActive }), jsx(LegendLabel, { "$active": item.isActive, children: item.label })] }, item.id)) }), [legend, legendItems, handleLegendClick]); // Handle chart data point click with formatted value feedback const handleDataPointClick = event => { if (!chartRef.current) return; const chart = chartRef.current; const points = chart.getElementsAtEventForMode(event.nativeEvent, 'nearest', { intersect: true }, false); if ((points?.length || 0) > 0) { const firstPoint = points[0]; const datasetIndex = firstPoint.datasetIndex; const dataIndex = firstPoint.index; const dataset = datasets ? datasets[datasetIndex] : null; const dataPoint = dataset ? dataset.data[dataIndex] : null; // --- Trigger Element Click Animation State Update --- if (getElementPhysicsOptions && dataPoint) { const physicsOptions = getElementPhysicsOptions(dataPoint, datasetIndex, dataIndex, chartType); if (physicsOptions?.clickEffect) { const key = `${datasetIndex}_${dataIndex}`; setElementAnimationTargets(prev => new Map(prev).set(key, { ...(prev.get(key) || {}), targetScale: physicsOptions.clickEffect?.scale ?? 1, targetOpacity: physicsOptions.clickEffect?.opacity ?? 1 // Add other effects })); // Click effect resets automatically via CSS transition if (process.env.NODE_ENV === 'development') { console.log(`[Chart Interaction] Set CLICK target for ${key}:`, physicsOptions.clickEffect); } } } // --- End Trigger --- // Apply oscillation if physics enabled (This is separate chart-wide effect) if (interaction.physicsHoverEffects && !isReducedMotion) { applyOscillation(0.5); } // Format the value for the click handler if (!dataPoint || !dataset) return; dataPoint.formatType || dataset.formatType || 'number'; ({ ...(dataset.formatOptions || {}), ...(dataPoint.formatOptions || {}) }); // We'll provide both raw and formatted value to the handler if (onDataPointClick) { formatValue(dataPoint.y); onDataPointClick(datasetIndex, dataIndex, dataPoint); } } }; // Optimized chart hover handler with debouncing for better performance const handleChartHover = useCallback(event => { if (!chartRef.current) return; // Exit early if BOTH interactions are disabled if (!interaction.physicsHoverEffects && !interaction.showTooltips) { setHoveredPoint(null); // Ensure tooltip state is cleared if disabled return; } // Clear previous hover animation targets first const previousHoveredKey = hoveredPoint ? `${hoveredPoint.datasetIndex}_${hoveredPoint.dataIndex}` : null; const chart = chartRef.current; const points = chart.getElementsAtEventForMode(event.nativeEvent, 'nearest', { intersect: false }, false); let currentHoveredKey = null; if ((points?.length || 0) > 0) { const firstPoint = points[0]; const datasetIndex = firstPoint.datasetIndex; const dataIndex = firstPoint.index; currentHoveredKey = `${datasetIndex}_${dataIndex}`; // --- Trigger Element Hover Animation State Update --- // Check if physicsHoverEffects is enabled BEFORE updating targets if (interaction.physicsHoverEffects && getElementPhysicsOptions && datasets) { const dataset = datasets[datasetIndex]; if (!dataset) return; const dataPoint = dataset.data[dataIndex]; if (!dataPoint) return; const physicsOptions = getElementPhysicsOptions(dataPoint, datasetIndex, dataIndex, chartType); if (physicsOptions?.hoverEffect) { setElementAnimationTargets(prev => new Map(prev).set(currentHoveredKey, { ...(prev.get(currentHoveredKey) || {}), targetScale: physicsOptions.hoverEffect?.scale ?? 1, targetOpacity: physicsOptions.hoverEffect?.opacity ?? 1 // Add other effects })); if (process.env.NODE_ENV === 'development') { console.log(`[Chart Interaction] Set HOVER target for ${currentHoveredKey}:`, physicsOptions.hoverEffect); } } } // --- End Trigger --- // Update tooltip state only if enabled if (interaction.showTooltips && datasets) { const dataset = datasets[datasetIndex]; if (!dataset) return; const dataPoint = dataset.data[dataIndex]; if (!dataPoint) return; setHoveredPoint({ datasetIndex, dataIndex, x: event.clientX, y: event.clientY, value: { dataset: dataset.label, label: dataPoint.label || dataPoint.x, value: dataPoint.y, color: dataset.style?.lineColor || palette[datasetIndex % (palette?.length || 0)], extra: dataPoint.extra, formatType: dataPoint.formatType, formatOptions: dataPoint.formatOptions } }); } } else { // Clear tooltip state if enabled if (interaction.showTooltips) { setHoveredPoint(null); } } // Reset animation targets for previously hovered element if it's different // Only reset if physics hover effects are enabled if (interaction.physicsHoverEffects && previousHoveredKey && previousHoveredKey !== currentHoveredKey) { setElementAnimationTargets(prev => new Map(prev).set(previousHoveredKey, { ...(prev.get(previousHoveredKey) || {}), targetScale: 1, targetOpacity: 1 // Reset other effects })); if (process.env.NODE_ENV === 'development') { console.log(`[Chart Interaction] Reset HOVER target for ${previousHoveredKey}`); } } }, [hoveredPoint, interaction.physicsHoverEffects, interaction.showTooltips, getElementPhysicsOptions, datasets, chartType, palette]); // Optimized chart hover leave handler const handleChartLeave = useCallback(() => { // Clear tooltip state if enabled if (interaction.showTooltips) { setHoveredPoint(null); } // Reset all hover targets on leave ONLY if physics effects are enabled if (interaction.physicsHoverEffects) { let resetOccurred = false; setElementAnimationTargets(prev => { const next = new Map(prev); for (const key of next.keys()) { const current = next.get(key); if (current?.targetScale !== 1 || current?.targetOpacity !== 1) { next.set(key, { ...current, targetScale: 1, targetOpacity: 1 }); resetOccurred = true; // Mark that at least one reset happened } } return next; }); if (resetOccurred) { if (process.env.NODE_ENV === 'development') { console.log('[Chart Interaction] Reset ALL HOVER targets on leave'); } } } }, [interaction.showTooltips, interaction.physicsHoverEffects]); // Handle enhanced chart export useCallback(() => { if (!chartRef.current && chartType !== 'kpi') return; const chart = chartRef.current; // For KPI display, use a different export method if (chartType === 'kpi' && containerRef.current) { // Use html2canvas or another library to capture the KPI display try { // Simplified export - in a real implementation we'd use something like html2canvas const link = document.createElement('a'); link.download = `${exportOptions.filename || 'kpi'}.png`; link.href = '#'; document.body.appendChild(link); link.click(); document.body.removeChild(link); return; } catch (e) { if (process.env.NODE_ENV === 'development') { console.error('Failed to export KPI', e); } return; } } // Create a temporary canvas for the export const exportCanvas = document.createElement('canvas'); const exportContext = exportCanvas.getContext('2d'); if (!exportContext || !chart) return; // Determine dimensions and scaling const sourceCanvas = chart.canvas; const devicePixelRatio = window.devicePixelRatio || 1; // Set the export canvas size with device pixel ratio for high-quality exports exportCanvas.width = sourceCanvas.width * devicePixelRatio; exportCanvas.height = sourceCanvas.height * devicePixelRatio; // If title should be included, make room for it let titleHeight = 0; if (exportOptions.includeTitle && (title || subtitle)) { titleHeight = title && subtitle ? 60 : 40; exportCanvas.height += titleHeight * devicePixelRatio; } // Fill background if specified if (exportOptions.backgroundColor && exportOptions.backgroundColor !== 'transparent') { exportContext.fillStyle = exportOptions.backgroundColor; exportContext.fillRect(0, 0, exportCanvas.width, exportCanvas.height); } // Add title and subtitle if needed if (exportOptions.includeTitle && (title || subtitle)) { exportContext.textAlign = 'center'; exportContext.textBaseline = 'middle'; if (title) { exportContext.font = `bold ${16 * devicePixelRatio}px Inter, sans-serif`; exportContext.fillStyle = 'var(--glass-white)'; exportContext.fillText(title, exportCanvas.width / 2, 25 * devicePixelRatio); } if (subtitle) { exportContext.font = `${14 * devicePixelRatio}px Inter, sans-serif`; exportContext.fillStyle = '${glassStyles.text?.secondary || "rgba(var(--glass-color-white) / var(--glass-opacity-70))"}'; exportContext.fillText(subtitle, exportCanvas.width / 2, title ? 45 * devicePixelRatio : 25 * devicePixelRatio); } } // Draw the chart onto the export canvas exportContext.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, titleHeight * devicePixelRatio, exportCanvas.width, exportCanvas.height - titleHeight * devicePixelRatio); // Generate a filename with optional timestamp let filename = exportOptions.filename || 'chart'; if (exportOptions.includeTimestamp) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); filename += `_${timestamp}`; } // Determine format and quality const format = exportOptions.format === 'jpeg' ? 'image/jpeg' : 'image/png'; const quality = exportOptions.format === 'jpeg' ? exportOptions.quality : undefined; // Create a data URL for the export const dataUrl = exportCanvas.toDataURL(format, quality); // Create a temporary link and trigger download const link = document.createElement('a'); link.download = `${filename}.${exportOptions.format}`; link.href = dataUrl; document.body.appendChild(link); link.click(); document.body.removeChild(link); }, [chartRef, containerRef, chartType, title, subtitle, exportOptions, kpi]); // Combined ref callback for ChartJS instance const chartRefCallback = useCallback(instance => { if (chartRef.current) { chartRef.current = instance; } // Call the forwarded ref if it exists if (typeof ref === 'function') { ref(instance); } else if (ref && ref.current !== undefined) { ref.current = instance; } }, [ref]); // Memoize chart options const chartOptions = useMemo(() => ({ responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, // Disable built-in legend tooltip: { enabled: false }, // Disable built-in tooltip // Configure our custom interaction plugin [GalileoElementInteractionPlugin.id]: { elementAnimationTargets, setElementAnimationTargets, getElementPhysicsOptions, isReducedMotion } }, scales: axis ? { x: { display: axis.showXLabels, grid: { display: axis.showXGrid, color: axis.gridColor }, ticks: { color: axis.axisColor } }, y: { display: axis.showYLabels, grid: { display: axis.showYGrid, color: axis.gridColor }, ticks: { color: axis.axisColor } } } : undefined, animation: animation?.physicsEnabled ? { duration: animation.duration || 1000, easing: animation.easing || 'easeOutQuart', delay: 0, loop: false, animateRotate: true, animateScale: true } : false }), [axis, animation, elementAnimationTargets, getElementPhysicsOptions, isReducedMotion]); // Dependencies // Plugins to pass to the Chart component // Ensure all used plugins are registered above const chartPlugins = useMemo(() => [pathAnimationPlugin, GalileoElementInteractionPlugin // Add other custom plugins if needed ], []); return jsxs(ChartContainer, { ref: containerRef, className: cn('glass-data-chart', className), style: { width, height, ...style }, "$glassVariant": glassVariant, "$blurStrength": adaptedBlurStrength, "$color": color, "$elevation": typeof elevation === 'string' ? elevation === 'level1' ? 1 : elevation === 'level2' ? 2 : elevation === 'level3' ? 3 : elevation === 'level4' ? 4 : elevation === 'level5' ? 5 : 2 : elevation, "$borderRadius": borderRadius, "$borderColor": borderColor, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, ...restProps, children: [svgFilters, jsxs(ChartHeader, { children: [title && jsx(ChartTitle, { children: title }), subtitle && jsx(ChartSubtitle, { children: subtitle })] }), jsxs(ChartWrapper, { ref: chartWrapperRef, children: [interaction.zoomPanEnabled && jsx(ZoomControls, { onZoomIn: handleZoomIn, onZoomOut: handleZoomOut, onReset: resetZoom, zoomLevel: zoomLevel, "$variant": glassVariant }), jsx(Chart$1, { type: getChartJsType(), data: chartData, options: chartOptions, plugins: chartPlugins, ref: chartRefCallback, onClick: handleDataPointClick, onMouseMove: handleChartHover, onMouseLeave: handleChartLeave, "aria-label": chartAriaLabel }, chartType)] }), legend.show && renderLegend(), interaction.tooltipStyle === 'dynamic' ? hoveredPoint && interaction.showTooltips && jsxs(DynamicTooltip, { "$color": color, "$quality": typeof activeQuality === 'string' ? activeQuality : activeQuality.tier, style: { left: `${hoveredPoint.x ?? 0}px`, top: `${hoveredPoint.y ?? 0}px` }, children: [jsx(TooltipHeader, { "$color": hoveredPoint.value?.color || 'var(--glass-white)', children: hoveredPoint.value?.dataset || 'Data' }), jsxs(TooltipRow, { children: [jsxs(TooltipLabel, { children: [typeof hoveredPoint.value?.label === 'string' ? hoveredPoint.value.label : 'Value', ": "] }), jsx(TooltipValue, { "$highlighted": true, children: formatValue(hoveredPoint.value?.value ?? 0) })] }), hoveredPoint.value?.extra && Object.entries(hoveredPoint.value.extra).map(([key, value]) => jsxs(TooltipRow, { children: [jsxs(TooltipLabel, { children: [key, ":"] }), jsx(TooltipValue, { children: String(value) })] }, key))] }) : hoveredPoint && interaction.showTooltips && jsx(GlassTooltip, { position: "top", showArrow: true, content: jsxs("div", { children: [jsx("div", { style: { color: hoveredPoint.value?.color || 'var(--glass-white)' }, children: String(hoveredPoint.value?.dataset ?? 'Dataset') }), jsxs("div", { children: [jsx("strong", { children: typeof hoveredPoint.value?.label === 'string' ? hoveredPoint.value.label : 'Value' }), ": ", hoveredPoint.value?.value ?? 'N/A'] }), hoveredPoint.value?.extra && Object.entries(hoveredPoint.value.extra).map(([key, value]) => jsxs("div", { children: [jsx("strong", { children: key }), ": ", typeof value === 'string' || typeof value === 'number' ? value : String(value)] }, key))] }), children: jsx("div", { style: { position: 'absolute', top: hoveredPoint.y, left: hoveredPoint.x, width: 1, height: 1, pointerEvents: 'none', zIndex: 100 } }) })] }); }); // Add displayName for better debugging GlassDataChartComponent.displayName = 'GlassDataChart'; // Export memoized component for better performance const GlassDataChart = /*#__PURE__*/memo(GlassDataChartComponent); export { GlassDataChart, GlassDataChart as default }; //# sourceMappingURL=GlassDataChart.js.map