aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
431 lines (428 loc) • 17.1 kB
JavaScript
'use client';
import { jsx, jsxs } from 'react/jsx-runtime';
import { cn } from '../../lib/utilsComprehensive.js';
import { forwardRef, useState, useRef, useMemo, useCallback } from 'react';
import { useMotionPreferenceContext } from '../../contexts/MotionPreferenceContext.js';
import '../../primitives/GlassCore.js';
import '../../primitives/glass/GlassAdvanced.js';
import { OptimizedGlassCore } from '../../primitives/OptimizedGlassCore.js';
import '../../primitives/glass/OptimizedGlassAdvanced.js';
import '../../primitives/MotionNative.js';
import { MotionFramer } from '../../primitives/motion/MotionFramer.js';
import { useA11yId } from '../../utils/a11y.js';
const GlassHeatmap = /*#__PURE__*/forwardRef(({
// TODO: Integrate ContrastGuard for table cells, list items, badges, card titles, and other text content for WCAG AA compliance
data: incomingData = [],
xAxis,
yAxis,
colorScale = {
min: "var(--glass-color-primary)",
max: "var(--glass-color-danger)"
},
cellSize = 20,
cellGap = 1,
showValues = false,
showGrid = true,
showTooltips = true,
selectable = false,
selectedCells: incomingSelectedCells = [],
onSelectionChange,
onCellClick,
onCellHover,
renderCell,
renderTooltip,
zoomable = false,
zoomLevel = 1,
onZoomChange,
showLegend = true,
legendPosition = "right",
animated = true,
animationDuration = 500,
respectMotionPreference = true,
className,
...props
}, ref) => {
const data = Array.isArray(incomingData) ? incomingData : [];
const selectedCells = Array.isArray(incomingSelectedCells) ? incomingSelectedCells : [];
const {
prefersReducedMotion
} = useMotionPreferenceContext();
const heatmapId = useA11yId("glass-heatmap");
const [hoveredCell, setHoveredCell] = useState(null);
const [tooltipPosition, setTooltipPosition] = useState({
x: 0,
y: 0
});
const [internalZoomLevel, setInternalZoomLevel] = useState(zoomLevel);
const containerRef = useRef(null);
const heatmapRef = useRef(null);
// Process data into normalized format
const processedData = useMemo(() => {
let cells = [];
let minValue = Infinity;
let maxValue = -Infinity;
if (!Array.isArray(data) || data.length === 0) {
return {
cells: [],
minValue: 0,
maxValue: 0,
range: 1
};
}
const firstEntry = data[0];
if (Array.isArray(firstEntry)) {
// Handle 2D array format
const matrix = data;
matrix.forEach((row, rowIndex) => {
(Array.isArray(row) ? row : []).forEach((value, colIndex) => {
minValue = Math.min(minValue, value);
maxValue = Math.max(maxValue, value);
cells.push({
row: rowIndex,
col: colIndex,
value,
normalizedValue: 0 // Will be calculated after we know min/max
});
});
});
} else {
// Handle data points format
const points = data;
points.forEach(point => {
if (typeof point?.value !== "number") return;
minValue = Math.min(minValue, point.value);
maxValue = Math.max(maxValue, point.value);
cells.push({
row: point.y ?? 0,
col: point.x ?? 0,
value: point.value,
normalizedValue: 0,
// Will be calculated after we know min/max
label: point.label,
metadata: point.metadata
});
});
}
if (cells.length === 0) {
return {
cells: [],
minValue: 0,
maxValue: 0,
range: 1
};
}
// Normalize values
const range = maxValue - minValue || 1;
cells = cells.map(cell => ({
...cell,
normalizedValue: (cell.value - minValue) / range
}));
return {
cells,
minValue,
maxValue,
range
};
}, [data]);
const hasCells = processedData.cells.length > 0;
// Get grid dimensions
const gridDimensions = useMemo(() => {
if (!processedData.cells.length) {
return {
rows: 0,
cols: 0
};
}
const maxRow = Math.max(...processedData.cells.map(c => c.row)) + 1;
const maxCol = Math.max(...processedData.cells.map(c => c.col)) + 1;
return {
rows: maxRow,
cols: maxCol
};
}, [processedData.cells]);
// Generate color from normalized value
const getColor = useCallback(normalizedValue => {
const {
min,
mid,
max,
steps = 100
} = colorScale;
if (mid) {
// Three-color gradient
if (normalizedValue <= 0.5) {
return interpolateColor(min, mid, normalizedValue * 2);
} else {
return interpolateColor(mid, max, (normalizedValue - 0.5) * 2);
}
} else {
// Two-color gradient
return interpolateColor(min, max, normalizedValue);
}
}, [colorScale]);
// Color interpolation utility
const interpolateColor = useCallback((color1, color2, factor) => {
const hex1 = color1.replace("#", "");
const hex2 = color2.replace("#", "");
const r1 = parseInt(hex1.substr(0, 2), 16);
const g1 = parseInt(hex1.substr(2, 2), 16);
const b1 = parseInt(hex1.substr(4, 2), 16);
const r2 = parseInt(hex2.substr(0, 2), 16);
const g2 = parseInt(hex2.substr(2, 2), 16);
const b2 = parseInt(hex2.substr(4, 2), 16);
const r = Math.round(r1 + factor * (r2 - r1));
const g = Math.round(g1 + factor * (g2 - g1));
const b = Math.round(b1 + factor * (b2 - b1));
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}, []);
// Handle cell interactions
const handleCellClick = useCallback((cell, event) => {
if (selectable) {
const cellId = {
row: cell.row,
col: cell.col
};
const isSelected = selectedCells.some(c => c.row === cell.row && c.col === cell.col);
let newSelection;
if (event.ctrlKey || event.metaKey) {
// Multi-select
newSelection = isSelected ? selectedCells.filter(c => !(c.row === cell.row && c.col === cell.col)) : [...selectedCells, cellId];
} else {
// Single select
newSelection = isSelected ? [] : [cellId];
}
onSelectionChange?.(newSelection);
}
onCellClick?.(cell);
}, [selectable, selectedCells, onSelectionChange, onCellClick]);
const handleCellHover = useCallback((cell, event) => {
setHoveredCell(cell);
onCellHover?.(cell);
if (cell && event && showTooltips) {
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
setTooltipPosition({
x: event.clientX - rect.left,
y: event.clientY - rect.top
});
}
}
}, [onCellHover, showTooltips]);
// Handle zoom
const handleZoom = useCallback((delta, event) => {
if (!zoomable) return;
event.preventDefault();
const newZoom = Math.max(0.5, Math.min(3, internalZoomLevel + delta));
setInternalZoomLevel(newZoom);
onZoomChange?.(newZoom);
}, [zoomable, internalZoomLevel, onZoomChange]);
// Check if cell is selected
const isCellSelected = useCallback((row, col) => {
return selectedCells.some(c => c.row === row && c.col === col);
}, [selectedCells]);
// Default cell renderer
const defaultRenderCell = useCallback(cell => {
const isSelected = isCellSelected(cell.row, cell.col);
const isHovered = hoveredCell?.row === cell.row && hoveredCell?.col === cell.col;
const cellColor = getColor(cell.normalizedValue);
return jsx(OptimizedGlassCore, {
elevation: "level1",
intensity: "subtle",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-heatmap-cell flex items-center justify-center glass-text-xs font-medium transition-all", "cursor-pointer hover:scale-110 hover:z-10", isSelected && "ring-2 ring-primary ring-offset-1", isHovered && "shadow-lg scale-110 z-20", showGrid && "border border-border/20"),
style: {
backgroundColor: cellColor,
color: cell.normalizedValue > 0.5 ? "var(--glass-white)" : "var(--glass-black)",
width: cellSize * internalZoomLevel,
height: cellSize * internalZoomLevel,
fontSize: `${Math.max(8, cellSize * internalZoomLevel * 0.4)}px`
},
onClick: e => handleCellClick(cell, e),
onMouseEnter: e => handleCellHover(cell, e),
onMouseLeave: () => handleCellHover(null),
children: showValues && jsx("span", {
className: 'select-none',
children: typeof cell.value === "number" ? cell.value.toFixed(1) : cell.value
})
});
}, [isCellSelected, hoveredCell, getColor, cellSize, internalZoomLevel, showGrid, showValues, handleCellClick, handleCellHover]);
// Default tooltip renderer
const defaultRenderTooltip = useCallback(cell => {
return jsx(OptimizedGlassCore, {
elevation: "level3",
intensity: "strong",
depth: 3,
tint: "neutral",
border: "strong",
className: "glass-heatmap-tooltip glass-p-3 glass-radius-lg glass-shadow-lg glass-glass-backdrop-blur-md glass-border glass-border-glass-border/20 glass-contrast-guard",
children: jsxs("div", {
className: "glass-text-sm glass-gap-1",
children: [jsx("div", {
className: 'font-semibold',
children: cell.label || `Cell (${cell.col}, ${cell.row})`
}), jsxs("div", {
children: ["Value: ", cell.value]
}), xAxis?.labels?.[cell.col] && jsxs("div", {
children: ["X: ", xAxis.labels[cell.col]]
}), yAxis?.labels?.[cell.row] && jsxs("div", {
children: ["Y: ", yAxis.labels[cell.row]]
}), cell.metadata && Object.entries(cell.metadata).map(([key, value]) => jsxs("div", {
children: [key, ": ", String(value)]
}, key))]
})
});
}, [xAxis, yAxis]);
// Render legend
const renderLegend = useCallback(() => {
if (!showLegend) return null;
const legendSteps = 20;
const isHorizontal = legendPosition === "top" || legendPosition === "bottom";
return jsxs(OptimizedGlassCore, {
elevation: "level2",
intensity: "medium",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-heatmap-legend glass-p-3 glass-radius-lg glass-backdrop-blur-md border border-border/20", isHorizontal ? "flex items-center glass-gap-3" : "flex flex-col glass-gap-3"),
children: [jsx("div", {
className: 'glass-text-sm font-medium text-primary',
children: "Legend"
}), jsxs("div", {
className: cn("flex", isHorizontal ? "flex-row items-center glass-gap-1" : "flex-col glass-gap-1"),
children: [jsx("div", {
className: "glass-text-xs glass-text-secondary",
children: processedData.minValue.toFixed(1)
}), jsx("div", {
className: cn("flex", isHorizontal ? "flex-row" : "flex-col"),
children: Array.from({
length: legendSteps
}, (_, i) => jsx("div", {
className: cn("flex-1", isHorizontal ? "w-3 h-6" : "w-6 h-3"),
style: {
backgroundColor: getColor(i / (legendSteps - 1))
}
}, i))
}), jsx("div", {
className: "glass-text-xs glass-text-secondary",
children: processedData.maxValue.toFixed(1)
})]
})]
});
}, [showLegend, legendPosition, processedData, getColor]);
// Create grid matrix for rendering
const gridMatrix = useMemo(() => {
const matrix = Array(gridDimensions.rows).fill(null).map(() => Array(gridDimensions.cols).fill(null));
processedData.cells.forEach(cell => {
if (cell.row < gridDimensions.rows && cell.col < gridDimensions.cols) {
matrix[cell.row][cell.col] = cell;
}
});
return matrix;
}, [processedData.cells, gridDimensions]);
return jsx(OptimizedGlassCore, {
ref: ref,
id: heatmapId,
elevation: "level1",
intensity: "subtle",
depth: 1,
tint: "neutral",
border: "subtle",
className: cn("glass-heatmap glass-radius-lg glass-backdrop-blur-md border border-border/20 overflow-hidden", className),
...props,
children: jsxs(MotionFramer, {
preset: !prefersReducedMotion && respectMotionPreference && animated ? "fadeIn" : "none",
className: 'relative',
children: [jsxs("div", {
ref: containerRef,
className: cn("flex", legendPosition === "left" && "flex-row-reverse", legendPosition === "right" && "flex-row", legendPosition === "top" && "flex-col-reverse", legendPosition === "bottom" && "flex-col"),
children: [showLegend && jsx("div", {
className: "glass-flex-shrink-0 glass-p-4",
children: renderLegend()
}), jsx("div", {
className: 'glass-flex-1 glass-p-6 overflow-auto',
children: !hasCells ? jsx("div", {
className: "glass-text-sm glass-text-secondary text-center glass-p-10",
children: "No heatmap data available."
}) : jsxs("div", {
className: "glass-flex",
children: [yAxis && jsxs("div", {
className: "glass-flex glass-flex-col glass-justify-between glass-mr-2",
children: [yAxis.title && jsx("div", {
className: 'glass-text-sm font-medium text-primary mb-2 writing-mode-vertical-lr transform rotate-180',
children: yAxis.title
}), jsx("div", {
className: "glass-flex glass-flex-col glass-justify-between glass-h-full",
children: yAxis.labels?.map((label, index) => jsx("div", {
className: 'glass-text-xs glass-text-secondary text-right pr-2',
children: label
}, index))
})]
}), jsxs("div", {
ref: heatmapRef,
className: 'relative',
onWheel: e => handleZoom(e.deltaY > 0 ? -0.1 : 0.1, e),
children: [xAxis && jsxs("div", {
className: 'mb-2',
children: [xAxis.title && jsx("div", {
className: 'glass-text-sm font-medium text-primary text-center mb-2',
children: xAxis.title
}), jsx("div", {
className: "glass-flex glass-justify-between",
style: {
width: (cellSize * internalZoomLevel + cellGap) * gridDimensions.cols - cellGap
},
children: xAxis.labels?.map((label, index) => jsx("div", {
className: 'glass-text-xs glass-text-secondary text-center',
children: label
}, index))
})]
}), jsx("div", {
className: "glass-grid glass-gap-px",
style: {
gridTemplateColumns: `repeat(${gridDimensions.cols}, ${cellSize * internalZoomLevel}px)`,
gridTemplateRows: `repeat(${gridDimensions.rows}, ${cellSize * internalZoomLevel}px)`,
gap: `${cellGap}px`
},
children: gridMatrix.map((row, rowIndex) => row.map((cell, colIndex) => jsx(MotionFramer, {
preset: !prefersReducedMotion && respectMotionPreference && animated ? "scaleIn" : "none",
delay: animated ? (rowIndex * gridDimensions.cols + colIndex) * 10 : 0,
children: cell ? renderCell ? renderCell(cell) : defaultRenderCell(cell) : jsx("div", {
className: "glass-surface-overlay glass-border glass-border-dashed glass-border-glass-border/30",
style: {
width: cellSize * internalZoomLevel,
height: cellSize * internalZoomLevel
}
})
}, `${rowIndex}-${colIndex}`)))
})]
})]
})
})]
}), showTooltips && hoveredCell && jsx("div", {
className: 'absolute pointer-events-none z-50',
style: {
left: tooltipPosition.x + 10,
top: tooltipPosition.y - 10,
transform: "translateY(-100%)"
},
children: renderTooltip ? renderTooltip(hoveredCell) : defaultRenderTooltip(hoveredCell)
}), zoomable && jsxs("div", {
className: 'absolute top-4 right-4 glass-flex glass-flex-col glass-gap-1',
children: [jsx("button", {
onClick: () => handleZoom(0.1, {}),
className: 'w-8 h-8 glass-flex glass-items-center glass-justify-center glass-radius-md glass-text-sm font-bold transition-all hover:scale-105 glass-focus glass-touch-target glass-contrast-guard',
children: "+"
}), jsx("button", {
onClick: () => handleZoom(-0.1, {}),
className: 'w-8 h-8 glass-flex glass-items-center glass-justify-center glass-radius-md glass-text-sm font-bold transition-all hover:scale-105 glass-focus glass-touch-target glass-contrast-guard',
children: "\u2212"
})]
})]
})
});
});
GlassHeatmap.displayName = "GlassHeatmap";
export { GlassHeatmap, GlassHeatmap as default };
//# sourceMappingURL=GlassHeatmap.js.map