UNPKG

aura-glass

Version:

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

557 lines (554 loc) 21.3 kB
'use client'; import { jsxs, jsx } from 'react/jsx-runtime'; import { useState, useRef, useMemo, useCallback } from 'react'; import { GlassCore } from '../../primitives/GlassCore.js'; import '../../primitives/glass/GlassAdvanced.js'; import '../../primitives/OptimizedGlassCore.js'; import '../../primitives/glass/OptimizedGlassAdvanced.js'; import '../../primitives/MotionNative.js'; import '../../primitives/motion/MotionFramer.js'; import { cn } from '../../lib/utilsComprehensive.js'; import { useAccessibility } from '../../hooks/useAccessibility.js'; // Chart rendering utility functions const createSVGPath = (points, type = "line") => { if (points.length === 0) return ""; let path = `M ${points[0].x} ${points[0].y}`; for (let i = 1; i < points.length; i++) { path += ` L ${points[i].x} ${points[i].y}`; } if (type === "area") { const firstPoint = points[0]; const lastPoint = points[points.length - 1]; path += ` L ${lastPoint.x} 100 L ${firstPoint.x} 100 Z`; } return path; }; const scaleValue = (value, domain, range) => { const [domainMin, domainMax] = domain; const [rangeMin, rangeMax] = range; return rangeMin + (value - domainMin) / (domainMax - domainMin) * (rangeMax - rangeMin); }; const generateColors = count => { const colors = ["var(--glass-color-primary)", "var(--glass-color-danger)", "var(--glass-color-success)", "var(--glass-color-warning)", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16", "#F97316", "#6366F1"]; if (count <= colors.length) { return colors.slice(0, count); } // Generate additional colors using HSL const additionalColors = []; for (let i = colors.length; i < count; i++) { const hue = i * 137.508 % 360; // Golden angle approximation additionalColors.push(`hsl(${hue}, 70%, 50%)`); } return [...colors, ...additionalColors]; }; const formatValue = (value, type) => { if (typeof value === "number") { if (type === "currency") { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); } else if (type === "percentage") { return `${(value * 100).toFixed(1)}%`; } else if (value > 1000000) { return `${(value / 1000000).toFixed(1)}M`; } else if (value > 1000) { return `${(value / 1000).toFixed(1)}K`; } return value.toLocaleString(); } if (value instanceof Date) { return value.toLocaleDateString(); } return String(value); }; const GlassAdvancedDataViz = ({ data, type = "line", title, subtitle, xAxisLabel, yAxisLabel, showLegend = true, showTooltip = true, showDataLabels = false, enableZoom = true, enablePan = true, enableCrosshair = true, enableAnimation = true, enableDrillDown = false, drillDownLevels = [], filters = [], onDataPointClick, onDrillDown, onExport, width = 800, height = 400, className }) => { const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, content: "" }); const [selectedSeries, setSelectedSeries] = useState(data.map(s => s.id)); const [zoomLevel, setZoomLevel] = useState({ x: [0, 1], y: [0, 1] }); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [currentDrillLevel, setCurrentDrillLevel] = useState(0); const [appliedFilters, setAppliedFilters] = useState(filters); useState(false); const svgRef = useRef(null); const containerRef = useRef(null); useAccessibility(); // Filter and process data const processedData = useMemo(() => { return data.map(series => ({ ...series, data: series.data.filter(point => { return appliedFilters.every(filter => { const fieldValue = point.metadata?.[filter.field] ?? point.y; switch (filter.operator) { case "equals": return fieldValue === filter.value; case "contains": return String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase()); case "greaterThan": return Number(fieldValue) > Number(filter.value); case "lessThan": return Number(fieldValue) < Number(filter.value); case "between": return Number(fieldValue) >= Number(filter.value[0]) && Number(fieldValue) <= Number(filter.value[1]); default: return true; } }); }) })).filter(series => selectedSeries.includes(series.id)); }, [data, appliedFilters, selectedSeries]); // Calculate chart dimensions and scales const chartDimensions = useMemo(() => { const margin = { top: 20, right: 20, bottom: 60, left: 60 }; const chartWidth = width - margin.left - margin.right; const chartHeight = height - margin.top - margin.bottom; // Calculate data bounds const allPoints = processedData.flatMap(s => s.data); let xMin = Infinity, xMax = -Infinity; let yMin = Infinity, yMax = -Infinity; allPoints.forEach(point => { const xVal = typeof point.x === "number" ? point.x : new Date(point.x).getTime(); xMin = Math.min(xMin, xVal); xMax = Math.max(xMax, xVal); yMin = Math.min(yMin, point.y); yMax = Math.max(yMax, point.y); }); // Add padding const xPadding = (xMax - xMin) * 0.05; const yPadding = (yMax - yMin) * 0.1; xMin -= xPadding; xMax += xPadding; yMin -= yPadding; yMax += yPadding; // Apply zoom const zoomedXMin = xMin + (xMax - xMin) * zoomLevel.x[0]; const zoomedXMax = xMin + (xMax - xMin) * zoomLevel.x[1]; const zoomedYMin = yMin + (yMax - yMin) * zoomLevel.y[0]; const zoomedYMax = yMin + (yMax - yMin) * zoomLevel.y[1]; return { margin, chartWidth, chartHeight, xDomain: [zoomedXMin, zoomedXMax], yDomain: [zoomedYMin, zoomedYMax] }; }, [processedData, width, height, zoomLevel]); // Generate chart colors const seriesColors = useMemo(() => { const colors = generateColors(processedData.length); const colorMap = {}; processedData.forEach((series, index) => { colorMap[series.id] = series.color || colors[index]; }); return colorMap; }, [processedData]); const handleMouseMove = useCallback(event => { if (!showTooltip) return; const svg = svgRef.current; if (!svg) return; const rect = svg.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; // Find nearest data point let nearestPoint = null; let nearestSeries = null; let minDistance = Infinity; processedData.forEach(series => { series.data.forEach(point => { const pointX = scaleValue(typeof point.x === "number" ? point.x : new Date(point.x).getTime(), chartDimensions.xDomain, [chartDimensions.margin.left, chartDimensions.margin.left + chartDimensions.chartWidth]); const pointY = scaleValue(point.y, chartDimensions.yDomain, [chartDimensions.margin.top + chartDimensions.chartHeight, chartDimensions.margin.top]); const distance = Math.sqrt(Math.pow(x - pointX, 2) + Math.pow(y - pointY, 2)); if (distance < minDistance && distance < 50) { // 50px tolerance minDistance = distance; nearestPoint = point; nearestSeries = series; } }); }); if (nearestPoint && nearestSeries) { const s = nearestSeries; const p = nearestPoint; setTooltip({ visible: true, x: event.clientX, y: event.clientY, content: `${s.name}: ${formatValue(p.y)} (${formatValue(p.x)})` }); } else { setTooltip(prev => ({ ...prev, visible: false })); } }, [showTooltip, processedData, chartDimensions]); const handleDataPointClick = useCallback((point, series) => { onDataPointClick?.(point, series); if (enableDrillDown && currentDrillLevel < drillDownLevels.length - 1) { const nextLevel = drillDownLevels[currentDrillLevel + 1]; const newFilter = { field: nextLevel.dataKey, operator: "equals", value: point.metadata?.[nextLevel.dataKey] || point.category }; setAppliedFilters(prev => [...prev, newFilter]); setCurrentDrillLevel(prev => prev + 1); onDrillDown?.(nextLevel, [...appliedFilters, newFilter]); } }, [onDataPointClick, enableDrillDown, currentDrillLevel, drillDownLevels, appliedFilters, onDrillDown]); const handleZoom = useCallback((delta, centerX, centerY) => { if (!enableZoom) return; const zoomFactor = delta > 0 ? 0.9 : 1.1; const xRange = zoomLevel.x[1] - zoomLevel.x[0]; const yRange = zoomLevel.y[1] - zoomLevel.y[0]; const newXRange = xRange * zoomFactor; const newYRange = yRange * zoomFactor; const xCenter = zoomLevel.x[0] + xRange * centerX; const yCenter = zoomLevel.y[0] + yRange * centerY; setZoomLevel({ x: [Math.max(0, xCenter - newXRange / 2), Math.min(1, xCenter + newXRange / 2)], y: [Math.max(0, yCenter - newYRange / 2), Math.min(1, yCenter + newYRange / 2)] }); }, [enableZoom, zoomLevel]); const resetZoom = useCallback(() => { setZoomLevel({ x: [0, 1], y: [0, 1] }); setPanOffset({ x: 0, y: 0 }); }, []); const toggleSeries = useCallback(seriesId => { setSelectedSeries(prev => prev.includes(seriesId) ? prev.filter(id => id !== seriesId) : [...prev, seriesId]); }, []); const exportChart = useCallback(format => { if (format === "csv" || format === "json") { const exportData = processedData.map(series => ({ series: series.name, data: series.data.map(point => ({ x: point.x, y: point.y, category: point.category, label: point.label, ...point.metadata })) })); const content = format === "csv" ? convertToCSV(exportData) : JSON.stringify(exportData, null, 2); const blob = new Blob([content], { type: format === "csv" ? "text/csv" : "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `chart-data.${format}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } onExport?.(format); }, [processedData, onExport]); const convertToCSV = data => { const rows = ["Series,X,Y,Category,Label"]; data.forEach(series => { series.data.forEach(point => { rows.push(`${series.series},${point.x},${point.y},${point.category || ""},${point.label || ""}`); }); }); return rows.join("\n"); }; const renderLineChart = () => { return processedData.map(series => { const points = series.data.map(point => ({ x: scaleValue(typeof point.x === "number" ? point.x : new Date(point.x).getTime(), chartDimensions.xDomain, [0, chartDimensions.chartWidth]), y: scaleValue(point.y, chartDimensions.yDomain, [chartDimensions.chartHeight, 0]) })); const pathD = createSVGPath(points, series.type === "area" ? "area" : "line"); return jsxs("g", { "data-glass-component": true, children: [series.type === "area" && jsx("path", { d: pathD, fill: seriesColors[series.id], fillOpacity: 0.3, stroke: "none" }), jsx("path", { d: pathD, fill: "none", stroke: seriesColors[series.id], strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", className: enableAnimation ? "transition-all duration-300" : "" }), series.data.map((point, index) => jsx("circle", { cx: points[index].x, cy: points[index].y, r: 4, fill: seriesColors[series.id], stroke: "white", strokeWidth: 2, className: 'cursor-pointer hover:r-6 transition-all', onClick: () => handleDataPointClick(point, series) }, `${point.id}-${index}`))] }, series.id); }); }; const renderBarChart = () => { const barWidth = chartDimensions.chartWidth / (processedData[0]?.data.length || 1) * 0.8; const barGroupWidth = barWidth / processedData.length; return processedData.map((series, seriesIndex) => jsx("g", { children: series.data.map((point, pointIndex) => { const x = pointIndex * barWidth + seriesIndex * barGroupWidth + barGroupWidth / 2; const y = scaleValue(point.y, chartDimensions.yDomain, [chartDimensions.chartHeight, 0]); const height = chartDimensions.chartHeight - y; return jsx("rect", { x: x, y: y, width: barGroupWidth * 0.8, height: height, fill: seriesColors[series.id], className: 'cursor-pointer hover:opacity-80 transition-all', onClick: () => handleDataPointClick(point, series) }, `${point.id}-${pointIndex}`); }) }, series.id)); }; const renderAxes = () => { const xTicks = 5; const yTicks = 5; return jsxs("g", { children: [jsx("line", { x1: 0, y1: chartDimensions.chartHeight, x2: chartDimensions.chartWidth, y2: chartDimensions.chartHeight, stroke: "var(--glass-gray-200)", strokeWidth: 1 }), jsx("line", { x1: 0, y1: 0, x2: 0, y2: chartDimensions.chartHeight, stroke: "var(--glass-gray-200)", strokeWidth: 1 }), Array.from({ length: xTicks }, (_, i) => { const x = i / (xTicks - 1) * chartDimensions.chartWidth; const value = chartDimensions.xDomain[0] + (chartDimensions.xDomain[1] - chartDimensions.xDomain[0]) * (i / (xTicks - 1)); return jsxs("g", { children: [jsx("line", { x1: x, y1: chartDimensions.chartHeight, x2: x, y2: chartDimensions.chartHeight + 5, stroke: "var(--glass-gray-400)", strokeWidth: 1 }), jsx("text", { x: x, y: chartDimensions.chartHeight + 20, textAnchor: "middle", className: 'glass-text-xs fill-gray-600', children: formatValue(value) })] }, i); }), Array.from({ length: yTicks }, (_, i) => { const y = chartDimensions.chartHeight - i / (yTicks - 1) * chartDimensions.chartHeight; const value = chartDimensions.yDomain[0] + (chartDimensions.yDomain[1] - chartDimensions.yDomain[0]) * (i / (yTicks - 1)); return jsxs("g", { children: [jsx("line", { x1: -5, y1: y, x2: 0, y2: y, stroke: "var(--glass-gray-400)", strokeWidth: 1 }), jsx("text", { x: -10, y: y + 4, textAnchor: "end", className: 'glass-text-xs fill-gray-600', children: formatValue(value) })] }, i); }), xAxisLabel && jsx("text", { x: chartDimensions.chartWidth / 2, y: chartDimensions.chartHeight + 45, textAnchor: "middle", className: 'glass-text-sm font-medium fill-gray-700', children: xAxisLabel }), yAxisLabel && jsx("text", { x: -40, y: chartDimensions.chartHeight / 2, textAnchor: "middle", className: 'glass-text-sm font-medium fill-gray-700', transform: `rotate(-90, -40, ${chartDimensions.chartHeight / 2})`, children: yAxisLabel })] }); }; return jsxs("div", { ref: containerRef, className: cn("relative", className), children: [jsxs(GlassCore, { className: "glass-p-6", children: [jsxs("div", { className: 'glass-flex glass-items-center glass-justify-between mb-6', children: [jsxs("div", { children: [title && jsx("h2", { className: 'glass-text-xl font-semibold glass-text-secondary', children: title }), subtitle && jsx("p", { className: 'glass-text-secondary mt-1', children: subtitle })] }), jsxs("div", { className: "glass-flex glass-items-center glass-gap-2", children: [enableZoom && jsx("button", { onClick: resetZoom, className: 'glass-px-3 glass-py-1 glass-text-xs glass-surface-subtle glass-text-secondary glass-radius hover:glass-surface-subtle transition-colors glass-focus glass-touch-target glass-contrast-guard', children: "Reset Zoom" }), jsxs("div", { className: 'glass-flex glass-border glass-border-subtle glass-radius overflow-hidden', children: [jsx("button", { onClick: () => exportChart("csv"), className: 'glass-px-3 glass-py-1 glass-text-xs glass-surface-subtle glass-text-secondary hover:glass-surface-subtle glass-border-r glass-border-subtle glass-focus glass-touch-target glass-contrast-guard', children: "CSV" }), jsx("button", { onClick: () => exportChart("json"), className: 'glass-px-3 glass-py-1 glass-text-xs glass-surface-subtle glass-text-secondary hover:glass-surface-subtle glass-focus glass-touch-target glass-contrast-guard', children: "JSON" })] })] })] }), enableDrillDown && appliedFilters.length > 0 && jsxs("div", { className: 'glass-flex glass-items-center glass-gap-2 mb-4 glass-text-sm glass-text-secondary', children: [jsx("span", { children: "\uD83D\uDCCA" }), jsx("span", { children: "Filtered by:" }), appliedFilters.map((filter, index) => jsxs("span", { className: 'glass-px-2 glass-py-1 glass-surface-subtle text-primary glass-radius glass-flex glass-items-center glass-gap-1', children: [filter.field, ": ", filter.value, jsx("button", { onClick: () => setAppliedFilters(prev => prev.filter((_, i) => i !== index)), className: 'text-primary hover:text-primary ml-1 glass-focus glass-touch-target glass-contrast-guard', children: "\u2715" })] }, index))] }), jsx("div", { className: 'relative', children: jsx("svg", { ref: svgRef, width: width, height: height, onMouseMove: handleMouseMove, onMouseLeave: () => setTooltip(prev => ({ ...prev, visible: false })), onWheel: e => { e.preventDefault(); const rect = svgRef.current?.getBoundingClientRect(); if (rect) { const centerX = (e.clientX - rect.left - chartDimensions.margin.left) / chartDimensions.chartWidth; const centerY = (e.clientY - rect.top - chartDimensions.margin.top) / chartDimensions.chartHeight; handleZoom(e.deltaY, centerX, centerY); } }, className: "glass-border glass-border-subtle glass-radius", children: jsxs("g", { transform: `translate(${chartDimensions.margin.left}, ${chartDimensions.margin.top})`, children: [renderAxes(), type === "line" || type === "combo" ? renderLineChart() : null, type === "bar" || type === "combo" ? renderBarChart() : null, jsx("defs", { children: jsx("pattern", { id: "grid", width: "40", height: "40", patternUnits: "userSpaceOnUse", children: jsx("path", { d: "M 40 0 L 0 0 0 40", fill: "none", stroke: "var(--glass-gray-100)", strokeWidth: "1" }) }) }), jsx("rect", { width: chartDimensions.chartWidth, height: chartDimensions.chartHeight, fill: "url(#grid)", opacity: "0.5" })] }) }) }), showLegend && jsx("div", { className: 'glass-flex glass-flex-wrap glass-items-center glass-gap-4 mt-4 pt-4 glass-border-t glass-border-subtle', children: processedData.map(series => jsxs("button", { onClick: () => toggleSeries(series.id), className: cn("flex items-center gap-2 px-3 py-1 rounded text-sm transition-opacity glass-focus glass-touch-target glass-contrast-guard", selectedSeries.includes(series.id) ? "opacity-100" : "opacity-50"), children: [jsx("div", { className: 'w-3 h-3 glass-radius-full', style: { backgroundColor: seriesColors[series.id] } }), jsx("span", { children: series.name })] }, series.id)) })] }), tooltip.visible && jsx("div", { className: 'fixed z-50 glass-px-3 glass-py-2 glass-surface-subtle text-primary glass-text-sm glass-radius glass-shadow-lg pointer-events-none', style: { left: tooltip.x + 10, top: tooltip.y - 10, transform: tooltip.x > window.innerWidth - 200 ? "translateX(-100%)" : "none" }, children: tooltip.content })] }); }; export { GlassAdvancedDataViz }; //# sourceMappingURL=GlassAdvancedDataViz.js.map