aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
1,255 lines (1,252 loc) • 44 kB
JavaScript
'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