aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
463 lines (460 loc) • 18.5 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { cn } from '../../lib/utilsComprehensive.js';
import { useState, useMemo } from 'react';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { CardHeader, CardTitle, CardContent } from '../card/index.js';
import { GlassCard } from '../card/GlassCard.js';
/**
* GlassAreaChart component
* A glassmorphism area chart with multiple series support and smooth area fills
*/
const GlassAreaChart = ({
// TODO: Integrate ContrastGuard in chart labels, tooltips, and legends for WCAG AA compliance
title,
series = [],
width = 600,
height = 300,
showGrid = true,
showPoints = false,
showLegend = true,
xAxisLabel,
yAxisLabel,
colors = ["var(--glass-color-primary)", "var(--glass-color-danger)", "var(--glass-color-success)", "var(--glass-color-warning)", "#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#ec4899", "var(--glass-gray-500)"],
animationDuration = 1200,
showTooltips = true,
formatYValue = value => value.toString(),
formatXValue = value => value.toString(),
fillOpacity = 0.6,
stacked = false,
className,
loading = false,
...props
}) => {
const [hoveredPoint, setHoveredPoint] = useState(null);
const [hoveredSeriesId, setHoveredSeriesId] = useState(null);
// Chart dimensions with padding
const padding = {
top: 20,
right: 60,
bottom: 60,
left: 60
};
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Process data for chart
const processedData = useMemo(() => {
if (!series || !Array.isArray(series) || series.length === 0) {
return {
scaledSeries: [],
xLabels: [],
yLabels: []
};
}
// Find min/max values across all series
const allPoints = series.flatMap(s => s.data);
const allXAreNumeric = allPoints.every(p => typeof p.x === "number");
const xValues = allPoints.map(p => typeof p.x === "number" ? p.x : 0);
const yValues = allPoints.map(p => p.y);
const xMin = Math.min(...xValues);
const xMax = Math.max(...xValues);
let yMin = Math.min(...yValues);
let yMax = Math.max(...yValues);
// For stacked charts, calculate cumulative values
let stackedSeries = series;
let stackedData;
if (stacked && series.length > 1) {
// Create stacked data by accumulating values at each x point
const xPoints = new Set(allPoints.map(p => p.x));
stackedData = Array.from(xPoints).map(x => {
let cumulativeY = 0;
return series.map(s => {
const point = s.data?.find(p => p.x === x);
const y = point ? point.y : 0;
cumulativeY += y;
return {
seriesId: s.id,
x,
y: cumulativeY,
originalY: y
};
});
});
// Calculate new min/max for stacked chart
yMin = 0;
yMax = Math.max(...stackedData.flat().map(p => p.y));
// Transform series data for stacked display
stackedSeries = series.map((s, index) => ({
...s,
data: stackedData.map(stackPoint => ({
x: stackPoint[index].x,
y: stackPoint[index].y,
label: s.data?.find(p => p.x === stackPoint[index].x)?.label
}))
}));
}
// Add some padding to Y axis
const yRange = yMax - yMin;
const yPadding = yRange === 0 ? yMax === 0 ? 1 : Math.abs(yMax) * 0.1 : yRange * 0.1;
const yMinPadded = Math.max(0, yMin - yPadding);
const yMaxPadded = yMax + yPadding;
// Scale functions with guards against divide-by-zero/NaN
const maxPoints = Math.max(...series.map(s => s.data?.length || 0), 1);
const useOrdinalX = !allXAreNumeric || xMax === xMin;
const scaleXBy = (x, idx) => {
if (chartWidth <= 0) return 0;
if (useOrdinalX) {
return maxPoints <= 1 ? 0 : idx / (maxPoints - 1) * chartWidth;
}
const denom = xMax - xMin || 1;
return (x - xMin) / denom * chartWidth;
};
const scaleY = y => {
if (chartHeight <= 0) return 0;
const denom = yMaxPadded - yMinPadded || 1;
return chartHeight - (y - yMinPadded) / denom * chartHeight;
};
// Process each series
const scaledSeries = stackedSeries.map((s, seriesIndex) => {
const rawPoints = s.data || [];
const points = rawPoints.map((point, pointIndex) => {
const sx = padding.left + scaleXBy(typeof point.x === "number" ? point.x : 0, pointIndex);
const sy = padding.top + scaleY(point.y);
return {
...point,
scaledX: Number.isFinite(sx) ? sx : padding.left,
scaledY: Number.isFinite(sy) ? sy : padding.top + chartHeight,
originalY: stacked && stackedData ? stackedData.find(sd => sd[seriesIndex].x === point.x)?.[seriesIndex].originalY || point.y : point.y
};
}).filter(p => Number.isFinite(p.scaledX) && Number.isFinite(p.scaledY));
// Generate area path
let areaPath = "";
if ((points?.length || 0) > 0) {
// Start from bottom-left
areaPath = `M ${points[0].scaledX} ${padding.top + chartHeight}`;
// Draw to first point
areaPath += ` L ${points[0].scaledX} ${points[0].scaledY}`;
// Draw through all points
for (let i = 1; i < (points?.length || 0); i++) {
areaPath += ` L ${points[i].scaledX} ${points[i].scaledY}`;
}
// Draw back to bottom-right and close
areaPath += ` L ${points[(points?.length || 0) - 1].scaledX} ${padding.top + chartHeight} Z`;
}
return {
...s,
color: s.color || colors[seriesIndex % (colors?.length || 0)],
points,
areaPath
};
});
// Generate axis labels
const xLabels = (scaledSeries[0]?.points || []).map(point => ({
x: Number.isFinite(point.scaledX) ? point.scaledX : padding.left,
y: height - padding.bottom + 20,
label: formatXValue(point.x)
}));
const yLabels = [0, 0.25, 0.5, 0.75, 1].map(ratio => {
const value = yMinPadded + (yMaxPadded - yMinPadded) * ratio;
return {
x: padding.left - 10,
y: padding.top + chartHeight - chartHeight * ratio + 4,
label: formatYValue(value)
};
});
return {
scaledSeries,
xLabels,
yLabels
};
}, [series, width, height, padding, chartWidth, chartHeight, formatYValue, formatXValue, colors, stacked]);
// Generate path for line
const generatePath = points => {
const valid = (points || []).filter(p => Number.isFinite(p?.scaledX) && Number.isFinite(p?.scaledY));
if (valid.length === 0) return "";
let path = `M ${valid[0].scaledX} ${valid[0].scaledY}`;
for (let i = 1; i < valid.length; i++) {
path += ` L ${valid[i].scaledX} ${valid[i].scaledY}`;
}
return path;
};
// Handle point hover
const handlePointHover = (seriesId, pointIndex, x, y, _event) => {
if (showTooltips) {
const values = processedData.scaledSeries.map(s => s.points[pointIndex]?.originalY || 0);
setHoveredPoint({
seriesId,
index: pointIndex,
x,
y,
values
});
}
};
const handlePointLeave = () => {
setHoveredPoint(null);
};
// Loading skeleton
if (loading) {
return jsx(GlassCard, {
"data-glass-component": true,
className: cn("glass-p-6", className),
children: jsxs("div", {
className: 'animate-pulse glass-gap-4',
children: [jsx("div", {
className: 'h-6 glass-surface-subtle/20 glass-radius-md w-48'
}), jsx("div", {
className: 'h-64 glass-surface-subtle/10 glass-radius-md'
}), jsxs("div", {
className: "glass-flex glass-justify-center glass-gap-4",
children: [jsx("div", {
className: 'h-4 glass-surface-subtle/20 glass-radius-md w-20'
}), jsx("div", {
className: 'h-4 glass-surface-subtle/20 glass-radius-md w-20'
}), jsx("div", {
className: 'h-4 glass-surface-subtle/20 glass-radius-md w-20'
})]
})]
})
});
}
return jsx(MotionFramer, {
preset: "fadeIn",
className: "glass-w-full",
children: jsxs(GlassCard, {
className: cn("overflow-hidden", className),
...props,
children: [title && jsx(CardHeader, {
children: jsx(CardTitle, {
className: 'text-primary glass-text-lg font-semibold',
children: title
})
}), jsxs(CardContent, {
className: "glass-p-4",
children: [jsxs("div", {
className: 'relative glass-chart',
children: [jsxs("svg", {
width: width,
height: height,
className: 'overflow-visible',
children: [showGrid && jsxs("g", {
className: 'opacity-20',
children: [processedData.yLabels.map((label, index) => jsx("line", {
x1: padding.left,
y1: label.y - 4,
x2: width - padding.right,
y2: label.y - 4,
stroke: "currentColor",
strokeWidth: "1",
className: 'text-primary/30'
}, `h-grid-${index}`)), processedData.xLabels.map((label, index) => jsx("line", {
x1: label.x,
y1: padding.top,
x2: label.x,
y2: height - padding.bottom,
stroke: "currentColor",
strokeWidth: "1",
className: 'text-primary/30'
}, `v-grid-${index}`))]
}), processedData.scaledSeries.map((s, seriesIndex) => jsxs(MotionFramer, {
preset: "fadeIn",
delay: seriesIndex * 200,
className: 'relative',
children: [jsx("defs", {
children: jsxs("linearGradient", {
id: `area-gradient-${s.id}`,
x1: "0%",
y1: "0%",
x2: "0%",
y2: "100%",
children: [jsx("stop", {
offset: "0%",
stopColor: s.color,
stopOpacity: fillOpacity
}), jsx("stop", {
offset: "100%",
stopColor: s.color,
stopOpacity: fillOpacity * 0.3
})]
})
}), jsx("path", {
d: s.areaPath,
fill: `url(#area-gradient-${s.id})`,
className: 'transition-opacity duration-300 hover:opacity-80',
style: {
animation: `areaFill ${animationDuration}ms ease-out ${seriesIndex * 200}ms both`,
opacity: hoveredSeriesId && hoveredSeriesId !== s.id ? 0.35 : 1
}
})]
}, `${s.id}-area`)), processedData.scaledSeries.map((s, seriesIndex) => jsxs(MotionFramer, {
preset: "slideUp",
delay: seriesIndex * 100 + 300,
className: 'relative',
children: [jsx("path", {
d: generatePath(s.points),
fill: "none",
stroke: s.color,
strokeWidth: s.strokeWidth || 2,
className: 'drop-shadow-sm',
style: {
animation: `drawLine ${animationDuration}ms ease-out ${seriesIndex * 100 + 300}ms both`,
opacity: hoveredSeriesId && hoveredSeriesId !== s.id ? 0.35 : 1
}
}), showPoints && s.points.map((point, pointIndex) => jsx("circle", {
cx: point.scaledX,
cy: point.scaledY,
r: "4",
fill: s.color,
stroke: "rgba(var(--glass-color-white) / var(--glass-opacity-80))",
strokeWidth: "2",
className: 'cursor-pointer hover:r-6 transition-all duration-200',
role: "button",
tabIndex: 0,
"aria-label": `Data point ${pointIndex + 1} of ${s.name}: ${formatXValue(point.x)}, ${formatYValue(point.originalY || point.y)}`,
onMouseEnter: e => handlePointHover(s.id, pointIndex, point.scaledX, point.scaledY),
onMouseLeave: handlePointLeave,
onKeyDown: e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handlePointHover(s.id, pointIndex, point.scaledX, point.scaledY);
}
},
style: {
animation: `fadeInPoint 300ms ease-out ${animationDuration + seriesIndex * 100 + pointIndex * 50 + 300}ms both`,
opacity: hoveredSeriesId && hoveredSeriesId !== s.id ? 0.35 : 1
}
}, `${s.id}-point-${pointIndex}`))]
}, `${s.id}-line`)), hoveredPoint && jsxs("g", {
className: 'pointer-events-none',
children: [jsx("line", {
x1: hoveredPoint.x,
y1: padding.top,
x2: hoveredPoint.x,
y2: height - padding.bottom,
stroke: "white",
strokeOpacity: 0.25,
strokeDasharray: "4 4"
}), jsx("line", {
x1: padding.left,
y1: hoveredPoint.y,
x2: width - padding.right,
y2: hoveredPoint.y,
stroke: "white",
strokeOpacity: 0.25,
strokeDasharray: "4 4"
}), jsx("circle", {
cx: hoveredPoint.x,
cy: hoveredPoint.y,
r: "8",
fill: "none",
stroke: "white",
strokeOpacity: 0.35
})]
}), jsx("line", {
x1: padding.left,
y1: height - padding.bottom,
x2: width - padding.right,
y2: height - padding.bottom,
stroke: "currentColor",
strokeWidth: "1",
className: 'text-primary/50'
}), jsx("line", {
x1: padding.left,
y1: padding.top,
x2: padding.left,
y2: height - padding.bottom,
stroke: "currentColor",
strokeWidth: "1",
className: 'text-primary/50'
}), processedData.xLabels.map((label, index) => jsx("text", {
x: label.x,
y: label.y,
textAnchor: "middle",
className: 'glass-text-xs fill-white/70',
style: {
fontSize: "0.625rem"
},
children: label.label
}, `x-label-${index}`)), processedData.yLabels.map((label, index) => jsx("text", {
x: label.x,
y: label.y,
textAnchor: "end",
className: 'glass-text-xs fill-white/70',
style: {
fontSize: "0.625rem"
},
children: label.label
}, `y-label-${index}`)), xAxisLabel && jsx("text", {
x: width / 2,
y: height - 10,
textAnchor: "middle",
className: 'glass-text-sm fill-white/80 font-medium',
children: xAxisLabel
}), yAxisLabel && jsx("text", {
x: 15,
y: height / 2,
textAnchor: "middle",
transform: `rotate(-90, 15, ${height / 2})`,
className: 'glass-text-sm fill-white/80 font-medium',
children: yAxisLabel
})]
}), hoveredPoint && jsx(MotionFramer, {
preset: "fadeIn",
className: 'absolute z-10',
children: jsx("div", {
role: "tooltip",
"aria-live": "polite",
className: cn("absolute glass-radius-xl glass-p-3 shadow-xl", "bg-black/70 glass-backdrop-blur-md ring-1 ring-white/10 glass-radial-reveal glass-lift"),
style: {
left: hoveredPoint.x + 10,
top: hoveredPoint.y - 10,
transform: hoveredPoint.x > width / 2 ? "translateX(-100%)" : "none"
},
children: jsxs("div", {
className: 'text-primary glass-text-sm glass-gap-2',
children: [jsx("div", {
className: 'font-medium',
children: processedData.scaledSeries[0]?.points[hoveredPoint.index] && formatXValue(processedData.scaledSeries[0].points[hoveredPoint.index].x)
}), processedData.scaledSeries.map((s, index) => jsxs("div", {
className: "glass-flex glass-items-center glass-gap-2",
children: [jsx("div", {
className: 'w-3 h-3 glass-radius-md',
style: {
backgroundColor: s.color
}
}), jsxs("span", {
className: 'text-primary/80',
children: [s.name, ":", " ", formatYValue(hoveredPoint.values?.[index] || 0)]
})]
}, s.id))]
})
})
})]
}), showLegend && (processedData.scaledSeries?.length || 0) > 0 && jsx("div", {
className: 'glass-flex glass-flex-wrap glass-justify-center glass-gap-4 mt-6',
children: processedData.scaledSeries.map(s => jsxs("div", {
className: cn("flex items-center glass-gap-2 glass-px-2 glass-py-1 glass-radius-md transition-all duration-200 hover:-translate-y-0.5", hoveredSeriesId && hoveredSeriesId !== s.id ? "opacity-50" : "opacity-100"),
onMouseEnter: () => setHoveredSeriesId(s.id),
onMouseLeave: () => setHoveredSeriesId(null),
children: [jsx("div", {
className: 'w-3 h-3 glass-radius-md',
style: {
backgroundColor: s.color
}
}), jsx("span", {
className: 'glass-text-sm text-primary/80',
children: s.name
})]
}, s.id))
})]
})]
})
});
};
export { GlassAreaChart, GlassAreaChart as default };
//# sourceMappingURL=GlassAreaChart.js.map