From b498728c711375d82dc24bc8cfaad1d951206ebe Mon Sep 17 00:00:00 2001 From: Matty Evans Date: Thu, 15 Jan 2026 14:06:50 +1000 Subject: [PATCH] feat: add FlameGraph and StackedBar chart components for hierarchical data Introduce two new reusable chart components to visualize hierarchical and proportional data. FlameGraph renders nested call trees, file systems, or profiling data with width representing value and color indicating category. StackedBar displays horizontal stacked bars for gas breakdowns, budget allocation, or resource usage with interactive tooltips and legends. Both components include comprehensive Storybook stories covering EVM call trees, file system usage, performance profiles, budget examples, and edge cases like empty states and deep nesting. --- .../Charts/FlameGraph/FlameGraph.stories.tsx | 557 ++++++++++++++++++ .../Charts/FlameGraph/FlameGraph.tsx | 367 ++++++++++++ .../Charts/FlameGraph/FlameGraph.types.ts | 203 +++++++ src/components/Charts/FlameGraph/index.ts | 3 + .../Charts/StackedBar/StackedBar.stories.tsx | 479 +++++++++++++++ .../Charts/StackedBar/StackedBar.tsx | 367 ++++++++++++ .../Charts/StackedBar/StackedBar.types.ts | 157 +++++ src/components/Charts/StackedBar/index.ts | 2 + 8 files changed, 2135 insertions(+) create mode 100644 src/components/Charts/FlameGraph/FlameGraph.stories.tsx create mode 100644 src/components/Charts/FlameGraph/FlameGraph.tsx create mode 100644 src/components/Charts/FlameGraph/FlameGraph.types.ts create mode 100644 src/components/Charts/FlameGraph/index.ts create mode 100644 src/components/Charts/StackedBar/StackedBar.stories.tsx create mode 100644 src/components/Charts/StackedBar/StackedBar.tsx create mode 100644 src/components/Charts/StackedBar/StackedBar.types.ts create mode 100644 src/components/Charts/StackedBar/index.ts diff --git a/src/components/Charts/FlameGraph/FlameGraph.stories.tsx b/src/components/Charts/FlameGraph/FlameGraph.stories.tsx new file mode 100644 index 000000000..61f9b6aac --- /dev/null +++ b/src/components/Charts/FlameGraph/FlameGraph.stories.tsx @@ -0,0 +1,557 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { FlameGraph } from './FlameGraph'; +import { EVM_CALL_TYPE_COLORS, FILE_TYPE_COLORS, PROCESS_TYPE_COLORS } from './FlameGraph.types'; +import type { FlameGraphNode, FlameGraphColorMap } from './FlameGraph.types'; + +const meta = { + title: 'Components/Charts/FlameGraph', + component: FlameGraph, + parameters: { + layout: 'padded', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + minWidthPercent: { + control: { type: 'number', min: 0, max: 10, step: 0.1 }, + description: 'Minimum width % to render a node', + }, + height: { + control: { type: 'number' }, + description: 'Chart height in pixels', + }, + rowHeight: { + control: { type: 'number' }, + description: 'Height of each row', + }, + showLabels: { + control: { type: 'boolean' }, + description: 'Show labels on nodes', + }, + showLegend: { + control: { type: 'boolean' }, + description: 'Show legend', + }, + selectedNodeId: { + control: { type: 'text' }, + description: 'ID of selected node', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// EVM Call Tree Examples (Ethereum use case) +// ============================================================================ + +const EVM_CALL_TREE: FlameGraphNode = { + id: '0', + label: 'Root', + value: 1969778, + selfValue: 45000, + category: 'CALL', + children: [ + { + id: '1', + label: '0x7a25...Router', + value: 420000, + selfValue: 45000, + category: 'CALL', + metadata: { address: '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D', opcodeCount: 850 }, + children: [ + { + id: '2', + label: '0xPair...', + value: 180000, + selfValue: 180000, + category: 'STATICCALL', + metadata: { opcodeCount: 320 }, + }, + { + id: '3', + label: '0xToken...', + value: 95000, + selfValue: 95000, + category: 'CALL', + metadata: { opcodeCount: 180 }, + }, + { + id: '10', + label: '0xFactory...', + value: 100000, + selfValue: 100000, + category: 'STATICCALL', + metadata: { opcodeCount: 200 }, + }, + ], + }, + { + id: '4', + label: '0xImpl...', + value: 120000, + selfValue: 120000, + category: 'DELEGATECALL', + metadata: { opcodeCount: 280 }, + }, + { + id: '5', + label: '0xProxy...', + value: 800000, + selfValue: 200000, + category: 'CALL', + metadata: { opcodeCount: 450 }, + children: [ + { + id: '6', + label: '0xLogic...', + value: 600000, + selfValue: 300000, + category: 'DELEGATECALL', + metadata: { opcodeCount: 680 }, + children: [ + { + id: '7', + label: '0xOracle...', + value: 150000, + selfValue: 150000, + category: 'STATICCALL', + metadata: { opcodeCount: 220 }, + }, + { + id: '8', + label: '0xPool...', + value: 150000, + selfValue: 150000, + category: 'CALL', + metadata: { opcodeCount: 310 }, + }, + ], + }, + ], + }, + { + id: '9', + label: '0xCreate...', + value: 584778, + selfValue: 584778, + category: 'CREATE2', + metadata: { opcodeCount: 1200 }, + }, + ], +}; + +/** + * EVM transaction call tree with gas breakdown + */ +export const EVMCallTree: Story = { + args: { + data: EVM_CALL_TREE, + colorMap: EVM_CALL_TYPE_COLORS, + title: 'Transaction Call Tree', + valueUnit: 'gas', + }, +}; + +/** + * EVM call tree with errors + */ +export const EVMCallTreeWithErrors: Story = { + args: { + data: { + ...EVM_CALL_TREE, + children: [ + ...(EVM_CALL_TREE.children || []).slice(0, 2), + { + id: 'err', + label: '0xFailed...', + value: 500000, + selfValue: 500000, + category: 'CALL', + hasError: true, + metadata: { error: 'Out of gas' }, + }, + ], + }, + colorMap: EVM_CALL_TYPE_COLORS, + title: 'Failed Transaction', + valueUnit: 'gas', + }, +}; + +// ============================================================================ +// File System Examples +// ============================================================================ + +const FILE_TREE: FlameGraphNode = { + id: 'root', + label: '/', + value: 1024000000, + category: 'folder', + children: [ + { + id: 'home', + label: 'home', + value: 512000000, + category: 'folder', + children: [ + { + id: 'docs', + label: 'Documents', + value: 256000000, + category: 'folder', + children: [ + { id: 'doc1', label: 'report.pdf', value: 150000000, category: 'document' }, + { id: 'doc2', label: 'notes.txt', value: 50000000, category: 'file' }, + { id: 'doc3', label: 'data.xlsx', value: 56000000, category: 'document' }, + ], + }, + { + id: 'pics', + label: 'Pictures', + value: 180000000, + category: 'folder', + children: [ + { id: 'pic1', label: 'vacation.jpg', value: 80000000, category: 'image' }, + { id: 'pic2', label: 'family.png', value: 100000000, category: 'image' }, + ], + }, + { + id: 'vids', + label: 'Videos', + value: 76000000, + category: 'folder', + children: [{ id: 'vid1', label: 'clip.mp4', value: 76000000, category: 'video' }], + }, + ], + }, + { + id: 'var', + label: 'var', + value: 312000000, + category: 'folder', + children: [ + { id: 'log', label: 'log', value: 200000000, category: 'folder' }, + { id: 'cache', label: 'cache', value: 112000000, category: 'folder' }, + ], + }, + { + id: 'usr', + label: 'usr', + value: 200000000, + category: 'folder', + }, + ], +}; + +/** + * File system disk usage visualization + */ +export const FileSystemUsage: Story = { + args: { + data: FILE_TREE, + colorMap: FILE_TYPE_COLORS, + title: 'Disk Usage', + valueUnit: 'bytes', + valueFormatter: (v: number) => { + if (v >= 1e9) return `${(v / 1e9).toFixed(1)}GB`; + if (v >= 1e6) return `${(v / 1e6).toFixed(0)}MB`; + if (v >= 1e3) return `${(v / 1e3).toFixed(0)}KB`; + return `${v}B`; + }, + }, +}; + +// ============================================================================ +// Profiling / Performance Examples +// ============================================================================ + +const PROFILE_DATA: FlameGraphNode = { + id: 'main', + label: 'main()', + value: 1000, + selfValue: 50, + category: 'cpu', + children: [ + { + id: 'process', + label: 'processData()', + value: 600, + selfValue: 100, + category: 'cpu', + children: [ + { + id: 'read', + label: 'readFile()', + value: 200, + selfValue: 200, + category: 'io', + }, + { + id: 'parse', + label: 'parseJSON()', + value: 150, + selfValue: 150, + category: 'cpu', + }, + { + id: 'alloc', + label: 'allocateBuffers()', + value: 150, + selfValue: 150, + category: 'memory', + }, + ], + }, + { + id: 'network', + label: 'sendResults()', + value: 350, + selfValue: 50, + category: 'network', + children: [ + { + id: 'wait', + label: 'waitForResponse()', + value: 300, + selfValue: 300, + category: 'wait', + }, + ], + }, + ], +}; + +/** + * CPU/Memory profiling flame graph + */ +export const ProfilingData: Story = { + args: { + data: PROFILE_DATA, + colorMap: PROCESS_TYPE_COLORS, + title: 'Performance Profile', + valueUnit: 'ms', + }, +}; + +// ============================================================================ +// Generic Examples +// ============================================================================ + +const SIMPLE_TREE: FlameGraphNode = { + id: '0', + label: 'Root', + value: 1000, + selfValue: 100, + children: [ + { + id: '1', + label: 'Child A', + value: 500, + selfValue: 200, + children: [ + { id: '2', label: 'Grandchild 1', value: 200, selfValue: 200 }, + { id: '3', label: 'Grandchild 2', value: 100, selfValue: 100 }, + ], + }, + { + id: '4', + label: 'Child B', + value: 400, + selfValue: 400, + }, + ], +}; + +/** + * Default view with simple tree (no categories) + */ +export const Default: Story = { + args: { + data: SIMPLE_TREE, + title: 'Hierarchy', + colorMap: undefined, + showLegend: false, + }, +}; + +/** + * Custom color scheme + */ +export const CustomColors: Story = { + args: { + data: { + id: 'root', + label: 'Total Budget', + value: 1000000, + category: 'total', + children: [ + { id: 'eng', label: 'Engineering', value: 500000, category: 'engineering' }, + { id: 'mkt', label: 'Marketing', value: 300000, category: 'marketing' }, + { id: 'ops', label: 'Operations', value: 200000, category: 'operations' }, + ], + }, + colorMap: { + total: { bg: 'bg-slate-600', hover: 'hover:bg-slate-500' }, + engineering: { bg: 'bg-blue-500', hover: 'hover:bg-blue-400' }, + marketing: { bg: 'bg-green-500', hover: 'hover:bg-green-400' }, + operations: { bg: 'bg-amber-500', hover: 'hover:bg-amber-400' }, + } as FlameGraphColorMap, + title: 'Budget Allocation', + valueFormatter: (v: number) => `$${(v / 1000).toFixed(0)}K`, + }, +}; + +/** + * Interactive with click handler + */ +export const Interactive: Story = { + args: { + data: SIMPLE_TREE, + title: 'Click a Node', + onNodeClick: fn(), + onNodeHover: fn(), + }, +}; + +/** + * With selected node + */ +export const WithSelection: Story = { + args: { + data: SIMPLE_TREE, + title: 'Selection', + selectedNodeId: '1', + }, +}; + +/** + * Without labels (compact view) + */ +export const NoLabels: Story = { + args: { + data: SIMPLE_TREE, + title: 'Compact View', + showLabels: false, + rowHeight: 16, + }, +}; + +/** + * Without legend + */ +export const NoLegend: Story = { + args: { + data: EVM_CALL_TREE, + colorMap: EVM_CALL_TYPE_COLORS, + title: 'No Legend', + showLegend: false, + }, +}; + +/** + * Empty data + */ +export const Empty: Story = { + args: { + data: null, + title: 'No Data', + }, +}; + +/** + * Single node + */ +export const SingleNode: Story = { + args: { + data: { + id: '0', + label: 'Only Node', + value: 1000, + }, + title: 'Single Node', + height: 100, + }, +}; + +/** + * Deep nesting + */ +export const DeepNesting: Story = { + render: () => { + // Create deep tree programmatically + const createDeepTree = (depth: number, maxDepth: number, value: number): FlameGraphNode => { + const hasChildren = depth < maxDepth; + const childValue = hasChildren ? Math.floor(value * 0.8) : 0; + return { + id: `depth-${depth}`, + label: `Level ${depth}`, + value, + selfValue: value - childValue, + category: ['cpu', 'memory', 'io', 'network'][depth % 4], + children: hasChildren ? [createDeepTree(depth + 1, maxDepth, childValue)] : undefined, + }; + }; + + return ( + + ); + }, +}; + +/** + * Selection demo with state + */ +export const SelectionDemo: Story = { + render: () => { + const [selected, setSelected] = useState(undefined); + return ( +
+

Selected: {selected || 'None'} - Click a node

+ setSelected(node.id)} + /> +
+ ); + }, +}; + +/** + * Custom tooltip + */ +export const CustomTooltip: Story = { + args: { + data: PROFILE_DATA, + colorMap: PROCESS_TYPE_COLORS, + title: 'Custom Tooltip', + valueUnit: 'ms', + renderTooltip: node => ( +
+
{node.label}
+
+ Duration: {node.value}ms ({node.category}) +
+
+ ), + }, +}; diff --git a/src/components/Charts/FlameGraph/FlameGraph.tsx b/src/components/Charts/FlameGraph/FlameGraph.tsx new file mode 100644 index 000000000..a471e47ef --- /dev/null +++ b/src/components/Charts/FlameGraph/FlameGraph.tsx @@ -0,0 +1,367 @@ +import React, { type JSX, useMemo, useState, useCallback } from 'react'; +import clsx from 'clsx'; +import type { FlameGraphProps, FlameGraphNode, FlattenedNode, FlameGraphColorMap } from './FlameGraph.types'; +import { EVM_CALL_TYPE_COLORS } from './FlameGraph.types'; + +// Default colors +const DEFAULT_COLOR = { bg: 'bg-slate-500', hover: 'hover:bg-slate-400' }; +const ERROR_COLOR = { bg: 'bg-red-500', hover: 'hover:bg-red-400' }; + +/** + * Default formatter for values (K/M suffix) + */ +function defaultValueFormatter(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(0)}K`; + } + return value.toLocaleString(); +} + +/** + * Get color classes for a node + */ +function getNodeColors( + node: FlameGraphNode, + colorMap: FlameGraphColorMap | undefined, + defaultColor: { bg: string; hover: string }, + errorColor: { bg: string; hover: string } +): { bg: string; hover: string } { + // Error nodes always use error color + if (node.hasError) { + return errorColor; + } + + // Look up category in color map + if (node.category && colorMap?.[node.category]) { + return colorMap[node.category]; + } + + // Fallback to default + return defaultColor; +} + +/** + * Flatten the tree into rows for rendering + */ +function flattenTree( + node: FlameGraphNode, + rootValue: number, + depth: number = 0, + startPercent: number = 0, + parent?: FlameGraphNode +): FlattenedNode[] { + const widthPercent = (node.value / rootValue) * 100; + const result: FlattenedNode[] = [ + { + node, + depth, + startPercent, + widthPercent, + parent, + }, + ]; + + if (node.children && node.children.length > 0) { + let childStart = startPercent; + for (const child of node.children) { + result.push(...flattenTree(child, rootValue, depth + 1, childStart, node)); + childStart += (child.value / rootValue) * 100; + } + } + + return result; +} + +/** + * Calculate max depth of tree + */ +function getMaxDepth(node: FlameGraphNode, currentDepth: number = 0): number { + if (!node.children || node.children.length === 0) { + return currentDepth; + } + return Math.max(...node.children.map(child => getMaxDepth(child, currentDepth + 1))); +} + +/** + * Get unique categories from the tree + */ +function getCategories(node: FlameGraphNode, categories: Set = new Set()): Set { + if (node.category) { + categories.add(node.category); + } + if (node.children) { + for (const child of node.children) { + getCategories(child, categories); + } + } + return categories; +} + +/** + * FlameGraph - Hierarchical visualization for nested data + * + * A generic component for visualizing hierarchical data where: + * - Width represents value/size + * - Color indicates category/type + * - Depth shows hierarchy + * + * Can be used for call trees, file systems, org charts, profiling data, etc. + * + * @example + * ```tsx + * // EVM call tree + * + * + * // File system usage + * formatBytes(v)} + * /> + * ``` + */ +export function FlameGraph({ + data, + onNodeClick, + onNodeHover, + selectedNodeId, + colorMap = EVM_CALL_TYPE_COLORS, + defaultColor = DEFAULT_COLOR, + errorColor = ERROR_COLOR, + minWidthPercent = 0.5, + height = 400, + rowHeight = 24, + showLabels = true, + title, + valueUnit, + valueFormatter = defaultValueFormatter, + showLegend, + renderTooltip, +}: FlameGraphProps): JSX.Element { + const [hoveredNode, setHoveredNode] = useState(null); + + // Flatten tree and calculate layout + const { maxDepth, rows, categories } = useMemo(() => { + if (!data) { + return { maxDepth: 0, rows: [] as FlattenedNode[][], categories: new Set() }; + } + + const nodes = flattenTree(data, data.value); + const depth = getMaxDepth(data); + const cats = getCategories(data); + + // Group nodes by depth (row) + const rowMap = new Map(); + for (const node of nodes) { + const row = rowMap.get(node.depth) || []; + row.push(node); + rowMap.set(node.depth, row); + } + + const rowArray = Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([, nodes]) => nodes); + + return { + maxDepth: depth, + rows: rowArray, + categories: cats, + }; + }, [data]); + + // Determine if we should show legend + const shouldShowLegend = showLegend ?? (colorMap && categories.size > 0); + + // Handle mouse events + const handleMouseEnter = useCallback( + (node: FlameGraphNode) => { + setHoveredNode(node); + onNodeHover?.(node); + }, + [onNodeHover] + ); + + const handleMouseLeave = useCallback(() => { + setHoveredNode(null); + onNodeHover?.(null); + }, [onNodeHover]); + + const handleClick = useCallback( + (node: FlameGraphNode) => { + onNodeClick?.(node); + }, + [onNodeClick] + ); + + // Empty state + if (!data) { + return ( +
+ No data available +
+ ); + } + + const calculatedHeight = Math.max(height, (maxDepth + 1) * rowHeight + 60); + + return ( +
+ {/* Header */} + {title && ( +
+ {title} + + Total: {valueFormatter(data.value)} + {valueUnit && ` ${valueUnit}`} + +
+ )} + + {/* Flame Graph Container */} +
+
+ {rows.map((row, rowIndex) => ( +
0 ? 1 : 0 }} + > + {row.map(flatNode => { + // Skip nodes that are too small + if (flatNode.widthPercent < minWidthPercent) { + return null; + } + + const isSelected = flatNode.node.id === selectedNodeId; + const isHovered = flatNode.node.id === hoveredNode?.id; + const colors = getNodeColors(flatNode.node, colorMap, defaultColor, errorColor); + + return ( +
handleClick(flatNode.node)} + onMouseEnter={() => handleMouseEnter(flatNode.node)} + onMouseLeave={handleMouseLeave} + title={`${flatNode.node.label} - ${valueFormatter(flatNode.node.value)}${valueUnit ? ` ${valueUnit}` : ''}`} + > + {showLabels && flatNode.widthPercent > 3 && ( + + {flatNode.widthPercent > 8 + ? `${flatNode.node.label} (${valueFormatter(flatNode.node.value)})` + : flatNode.node.label} + + )} +
+ ); + })} +
+ ))} +
+ + {/* Tooltip */} + {hoveredNode && ( +
+ {renderTooltip ? ( + renderTooltip(hoveredNode) + ) : ( + <> +
{hoveredNode.label}
+
+ Total: + + {hoveredNode.value.toLocaleString()} + {valueUnit && ` ${valueUnit}`} + + + {hoveredNode.selfValue !== undefined && ( + <> + Self: + + {hoveredNode.selfValue.toLocaleString()} + {valueUnit && ` ${valueUnit}`} + + + )} + + {hoveredNode.category && ( + <> + Type: + {hoveredNode.category} + + )} + + {/* Render metadata fields */} + {hoveredNode.metadata && + Object.entries(hoveredNode.metadata).map(([key, value]) => ( + + {key}: + + {typeof value === 'number' ? value.toLocaleString() : String(value)} + + + ))} + + {hoveredNode.hasError && ( + <> + Status: + Error + + )} +
+ + )} +
+ )} +
+ + {/* Legend */} + {shouldShowLegend && colorMap && ( +
+ Categories: + {Array.from(categories).map(cat => { + const colors = colorMap[cat]; + if (!colors) return null; + return ( + + + {cat} + + ); + })} + + + Error + +
+ )} +
+ ); +} diff --git a/src/components/Charts/FlameGraph/FlameGraph.types.ts b/src/components/Charts/FlameGraph/FlameGraph.types.ts new file mode 100644 index 000000000..da8b6aa4f --- /dev/null +++ b/src/components/Charts/FlameGraph/FlameGraph.types.ts @@ -0,0 +1,203 @@ +/** + * Node in the flame graph tree structure + */ +export interface FlameGraphNode { + /** + * Unique identifier for this node + */ + id: string; + + /** + * Display label + */ + label: string; + + /** + * Total value for this node AND all descendants + */ + value: number; + + /** + * Value for this node only (excludes children) + */ + selfValue?: number; + + /** + * Child nodes + */ + children?: FlameGraphNode[]; + + /** + * Category/type for color mapping (e.g., "CALL", "STATICCALL", "folder", "component") + */ + category?: string; + + /** + * Whether this node has an error/warning state + */ + hasError?: boolean; + + /** + * Additional metadata (displayed in tooltip, passed to click handlers) + */ + metadata?: Record; +} + +/** + * Color mapping for node categories + * Key is the category name, value is the Tailwind color class + */ +export interface FlameGraphColorMap { + [category: string]: { + bg: string; + hover: string; + }; +} + +/** + * Props for the FlameGraph component + * + * A hierarchical visualization for displaying nested data. + * Can be used for call trees, file systems, org charts, or any hierarchical data. + */ +export interface FlameGraphProps { + /** + * Root node of the tree + */ + data: FlameGraphNode | null; + + /** + * Callback when a node is clicked + */ + onNodeClick?: (node: FlameGraphNode) => void; + + /** + * Callback when hovering over a node (null when leaving) + */ + onNodeHover?: (node: FlameGraphNode | null) => void; + + /** + * ID of the currently selected node + */ + selectedNodeId?: string; + + /** + * Custom color mapping for categories + * If not provided, uses default colors based on category or auto-assigns + */ + colorMap?: FlameGraphColorMap; + + /** + * Default color for nodes without a category match + * @default { bg: 'bg-slate-500', hover: 'hover:bg-slate-400' } + */ + defaultColor?: { bg: string; hover: string }; + + /** + * Color for error nodes (hasError: true) + * @default { bg: 'bg-red-500', hover: 'hover:bg-red-400' } + */ + errorColor?: { bg: string; hover: string }; + + /** + * Minimum width percentage to render a node (hides very small nodes) + * @default 0.5 + */ + minWidthPercent?: number; + + /** + * Height of the chart container in pixels + * @default 400 + */ + height?: number; + + /** + * Height of each row in pixels + * @default 24 + */ + rowHeight?: number; + + /** + * Show labels on nodes + * @default true + */ + showLabels?: boolean; + + /** + * Title displayed above the chart + */ + title?: string; + + /** + * Unit label for values (e.g., "gas", "bytes", "ms") + * @default undefined + */ + valueUnit?: string; + + /** + * Custom value formatter + * @default Formats with K/M suffix + */ + valueFormatter?: (value: number) => string; + + /** + * Show legend with category colors + * @default true if colorMap is provided + */ + showLegend?: boolean; + + /** + * Custom tooltip renderer + * If provided, replaces the default tooltip + */ + renderTooltip?: (node: FlameGraphNode) => React.ReactNode; +} + +/** + * Internal representation of a flattened node for rendering + */ +export interface FlattenedNode { + node: FlameGraphNode; + depth: number; + startPercent: number; + widthPercent: number; + parent?: FlameGraphNode; +} + +// ============================================================================ +// Preset Color Maps +// ============================================================================ + +/** + * EVM call type colors (for Ethereum transaction call trees) + */ +export const EVM_CALL_TYPE_COLORS: FlameGraphColorMap = { + CALL: { bg: 'bg-blue-500', hover: 'hover:bg-blue-400' }, + DELEGATECALL: { bg: 'bg-purple-500', hover: 'hover:bg-purple-400' }, + STATICCALL: { bg: 'bg-cyan-500', hover: 'hover:bg-cyan-400' }, + CALLCODE: { bg: 'bg-indigo-500', hover: 'hover:bg-indigo-400' }, + CREATE: { bg: 'bg-orange-500', hover: 'hover:bg-orange-400' }, + CREATE2: { bg: 'bg-amber-500', hover: 'hover:bg-amber-400' }, +}; + +/** + * File type colors (for file system visualization) + */ +export const FILE_TYPE_COLORS: FlameGraphColorMap = { + folder: { bg: 'bg-amber-500', hover: 'hover:bg-amber-400' }, + file: { bg: 'bg-blue-500', hover: 'hover:bg-blue-400' }, + image: { bg: 'bg-green-500', hover: 'hover:bg-green-400' }, + video: { bg: 'bg-purple-500', hover: 'hover:bg-purple-400' }, + document: { bg: 'bg-cyan-500', hover: 'hover:bg-cyan-400' }, +}; + +/** + * Process type colors (for profiling visualization) + */ +export const PROCESS_TYPE_COLORS: FlameGraphColorMap = { + cpu: { bg: 'bg-red-500', hover: 'hover:bg-red-400' }, + memory: { bg: 'bg-blue-500', hover: 'hover:bg-blue-400' }, + io: { bg: 'bg-green-500', hover: 'hover:bg-green-400' }, + network: { bg: 'bg-purple-500', hover: 'hover:bg-purple-400' }, + wait: { bg: 'bg-gray-500', hover: 'hover:bg-gray-400' }, +}; diff --git a/src/components/Charts/FlameGraph/index.ts b/src/components/Charts/FlameGraph/index.ts new file mode 100644 index 000000000..e2f3b9874 --- /dev/null +++ b/src/components/Charts/FlameGraph/index.ts @@ -0,0 +1,3 @@ +export { FlameGraph } from './FlameGraph'; +export type { FlameGraphProps, FlameGraphNode, FlameGraphColorMap, FlattenedNode } from './FlameGraph.types'; +export { EVM_CALL_TYPE_COLORS, FILE_TYPE_COLORS, PROCESS_TYPE_COLORS } from './FlameGraph.types'; diff --git a/src/components/Charts/StackedBar/StackedBar.stories.tsx b/src/components/Charts/StackedBar/StackedBar.stories.tsx new file mode 100644 index 000000000..50ad2c95e --- /dev/null +++ b/src/components/Charts/StackedBar/StackedBar.stories.tsx @@ -0,0 +1,479 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { StackedBar } from './StackedBar'; +import type { StackedBarSegment } from './StackedBar.types'; + +const meta = { + title: 'Components/Charts/StackedBar', + component: StackedBar, + parameters: { + layout: 'padded', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + showLabels: { + control: { type: 'boolean' }, + description: 'Show labels on segments', + }, + showPercentages: { + control: { type: 'boolean' }, + description: 'Show percentage values', + }, + showLegend: { + control: { type: 'boolean' }, + description: 'Show legend below the bar', + }, + animated: { + control: { type: 'boolean' }, + description: 'Enable animation', + }, + height: { + control: { type: 'number' }, + description: 'Component height in pixels', + }, + minWidthPercent: { + control: { type: 'number', min: 0, max: 10, step: 0.1 }, + description: 'Minimum % to render a segment', + }, + minLabelWidthPercent: { + control: { type: 'number', min: 0, max: 20, step: 1 }, + description: 'Minimum % to show labels', + }, + selectedSegmentName: { + control: { type: 'text' }, + description: 'Name of selected segment', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// Gas Breakdown Examples (Ethereum use case) +// ============================================================================ + +/** + * Gas breakdown for a typical Ethereum transaction + */ +export const GasBreakdown: Story = { + args: { + segments: [ + { name: 'Intrinsic', value: 46864, color: '#6366f1', description: 'Base transaction cost' }, + { name: 'EVM Execution', value: 761334, color: '#3b82f6', description: 'Smart contract execution' }, + ], + title: 'Gas Breakdown', + subtitle: 'Receipt: 646K', + footerLeft: 'Total consumed: 808K', + footerRight: 'Refund: -162K (capped at 20%)', + footerRightClassName: 'text-success', + }, +}; + +/** + * Gas breakdown with legend + */ +export const GasBreakdownWithLegend: Story = { + args: { + segments: [ + { name: 'Intrinsic', value: 46864, color: '#6366f1', description: 'Base transaction cost' }, + { name: 'EVM Execution', value: 761334, color: '#3b82f6', description: 'Smart contract execution' }, + ], + title: 'Gas Breakdown', + subtitle: 'Receipt: 646K', + showLegend: true, + }, +}; + +/** + * Gas breakdown for a failed transaction + */ +export const GasBreakdownFailed: Story = { + name: 'Gas Breakdown - Failed Transaction', + args: { + segments: [{ name: 'EVM Execution', value: 80000, color: '#3b82f6' }], + title: 'Gas Breakdown', + subtitle: 'Receipt: 80K', + footerLeft: 'Intrinsic: N/A (failed transaction)', + footerLeftClassName: 'text-warning', + }, +}; + +/** + * Gas breakdown with high EVM usage + */ +export const GasBreakdownEVMHeavy: Story = { + name: 'Gas Breakdown - EVM Heavy', + args: { + segments: [ + { name: 'Intrinsic', value: 21000, color: '#6366f1' }, + { name: 'EVM Execution', value: 2500000, color: '#3b82f6' }, + ], + title: 'Gas Breakdown', + subtitle: 'Receipt: 2.12M', + footerRight: 'Refund: -400K (capped)', + footerRightClassName: 'text-success', + }, +}; + +// ============================================================================ +// Interactive Examples +// ============================================================================ + +/** + * Interactive with click handler + */ +export const Interactive: Story = { + args: { + segments: [ + { name: 'Engineering', value: 500000, color: '#3b82f6', description: 'Development team budget' }, + { name: 'Marketing', value: 200000, color: '#10b981', description: 'Marketing campaigns' }, + { name: 'Operations', value: 150000, color: '#f59e0b', description: 'Day-to-day operations' }, + { name: 'Other', value: 50000, color: '#6b7280', description: 'Miscellaneous expenses' }, + ], + title: 'Annual Budget', + subtitle: 'Click a segment', + showLegend: true, + onSegmentClick: fn(), + onSegmentHover: fn(), + valueFormatter: (v: number) => `$${(v / 1000).toFixed(0)}K`, + }, +}; + +/** + * Selection demo with state + */ +export const SelectionDemo: Story = { + render: () => { + const [selected, setSelected] = useState(undefined); + const segments: StackedBarSegment[] = [ + { name: 'Engineering', value: 500000, color: '#3b82f6' }, + { name: 'Marketing', value: 200000, color: '#10b981' }, + { name: 'Operations', value: 150000, color: '#f59e0b' }, + { name: 'Other', value: 50000, color: '#6b7280' }, + ]; + + return ( +
+

Selected: {selected || 'None'} - Click a segment or legend item

+ setSelected(seg.name === selected ? undefined : seg.name)} + valueFormatter={(v: number) => `$${(v / 1000).toFixed(0)}K`} + /> +
+ ); + }, +}; + +// ============================================================================ +// Generic Examples +// ============================================================================ + +/** + * Default stacked bar with automatic colors + */ +export const Default: Story = { + args: { + segments: [ + { name: 'Category A', value: 450 }, + { name: 'Category B', value: 300 }, + { name: 'Category C', value: 150 }, + { name: 'Category D', value: 100 }, + ], + title: 'Distribution', + subtitle: 'Total: 1,000', + }, +}; + +/** + * With legend shown + */ +export const WithLegend: Story = { + args: { + segments: [ + { name: 'Category A', value: 450 }, + { name: 'Category B', value: 300 }, + { name: 'Category C', value: 150 }, + { name: 'Category D', value: 100 }, + ], + title: 'Distribution', + subtitle: 'Total: 1,000', + showLegend: true, + }, +}; + +/** + * Budget allocation example + */ +export const BudgetAllocation: Story = { + args: { + segments: [ + { name: 'Engineering', value: 500000, color: '#3b82f6' }, + { name: 'Marketing', value: 200000, color: '#10b981' }, + { name: 'Operations', value: 150000, color: '#f59e0b' }, + { name: 'Other', value: 50000, color: '#6b7280' }, + ], + title: 'Annual Budget', + subtitle: 'Total: $900K', + valueFormatter: (v: number) => `$${(v / 1000).toFixed(0)}K`, + }, +}; + +/** + * Resource usage example + */ +export const ResourceUsage: Story = { + args: { + segments: [ + { name: 'CPU', value: 72, color: '#ef4444' }, + { name: 'Memory', value: 45, color: '#3b82f6' }, + { name: 'Disk', value: 28, color: '#10b981' }, + ], + total: 100, + title: 'Resource Utilization', + subtitle: '145% total', + valueFormatter: (v: number) => `${v}%`, + showPercentages: false, + }, +}; + +/** + * Time distribution example + */ +export const TimeDistribution: Story = { + args: { + segments: [ + { name: 'Development', value: 32, color: '#8b5cf6' }, + { name: 'Meetings', value: 12, color: '#f59e0b' }, + { name: 'Code Review', value: 8, color: '#3b82f6' }, + { name: 'Documentation', value: 4, color: '#10b981' }, + ], + total: 40, + title: 'Weekly Time Allocation', + subtitle: '40 hours', + valueFormatter: (v: number) => `${v}h`, + footerLeft: 'Remaining: 0h', + showPercentages: true, + showLegend: true, + }, +}; + +/** + * Two segments only + */ +export const TwoSegments: Story = { + args: { + segments: [ + { name: 'Used', value: 750, color: '#3b82f6' }, + { name: 'Available', value: 250, color: '#e5e7eb' }, + ], + title: 'Storage', + subtitle: '75% used', + }, +}; + +/** + * Many small segments with auto-hidden labels + */ +export const ManySegments: Story = { + args: { + segments: [ + { name: 'A', value: 25 }, + { name: 'B', value: 20 }, + { name: 'C', value: 18 }, + { name: 'D', value: 12 }, + { name: 'E', value: 10 }, + { name: 'F', value: 8 }, + { name: 'G', value: 7 }, + ], + title: 'Category Distribution', + showLegend: true, + minLabelWidthPercent: 15, + }, +}; + +/** + * Without labels (compact view) + */ +export const NoLabels: Story = { + args: { + segments: [ + { name: 'Complete', value: 65, color: '#10b981' }, + { name: 'In Progress', value: 25, color: '#f59e0b' }, + { name: 'Pending', value: 10, color: '#6b7280' }, + ], + title: 'Project Status', + subtitle: '65% complete', + showLabels: false, + showLegend: true, + height: 100, + }, +}; + +/** + * Without percentages + */ +export const NoPercentages: Story = { + args: { + segments: [ + { name: 'Reads', value: 1250000 }, + { name: 'Writes', value: 450000 }, + ], + title: 'Database Operations', + showPercentages: false, + }, +}; + +/** + * Compact height + */ +export const Compact: Story = { + args: { + segments: [ + { name: 'Success', value: 95, color: '#10b981' }, + { name: 'Failed', value: 5, color: '#ef4444' }, + ], + total: 100, + height: 60, + showLabels: false, + }, +}; + +/** + * Large height with legend + */ +export const Large: Story = { + args: { + segments: [ + { name: 'Active', value: 1200, color: '#3b82f6' }, + { name: 'Inactive', value: 300, color: '#9ca3af' }, + ], + title: 'User Status', + subtitle: '1,500 total users', + height: 180, + showLegend: true, + }, +}; + +/** + * Without animation + */ +export const NoAnimation: Story = { + args: { + segments: [ + { name: 'Part A', value: 60 }, + { name: 'Part B', value: 40 }, + ], + animated: false, + }, +}; + +/** + * Single segment (edge case) + */ +export const SingleSegment: Story = { + args: { + segments: [{ name: 'Total', value: 1000, color: '#3b82f6' }], + title: 'Single Value', + }, +}; + +/** + * With very small segments (some hidden) + */ +export const SmallSegments: Story = { + args: { + segments: [ + { name: 'Large', value: 900 }, + { name: 'Medium', value: 80 }, + { name: 'Small', value: 15 }, + { name: 'Tiny', value: 4 }, + { name: 'Micro', value: 1 }, + ], + title: 'Size Distribution', + subtitle: 'Tiny segments auto-hidden', + showLegend: true, + minWidthPercent: 1, + }, +}; + +/** + * Empty state + */ +export const Empty: Story = { + args: { + segments: [], + title: 'No Data', + emptyMessage: 'No segments to display', + }, +}; + +/** + * Empty with custom message + */ +export const EmptyCustomMessage: Story = { + args: { + segments: [], + title: 'Transaction Data', + emptyMessage: 'Select a transaction to view gas breakdown', + }, +}; + +/** + * All zero values + */ +export const ZeroValues: Story = { + args: { + segments: [ + { name: 'A', value: 0 }, + { name: 'B', value: 0 }, + ], + title: 'Zero Values', + emptyMessage: 'All values are zero', + }, +}; + +/** + * Pre-selected segment + */ +export const PreSelected: Story = { + args: { + segments: [ + { name: 'Engineering', value: 500000, color: '#3b82f6' }, + { name: 'Marketing', value: 200000, color: '#10b981' }, + { name: 'Operations', value: 150000, color: '#f59e0b' }, + ], + title: 'Budget', + showLegend: true, + selectedSegmentName: 'Marketing', + }, +}; + +/** + * With segment descriptions in tooltips + */ +export const WithDescriptions: Story = { + args: { + segments: [ + { name: 'Frontend', value: 45, description: 'React, TypeScript, Tailwind' }, + { name: 'Backend', value: 35, description: 'Go, PostgreSQL, Redis' }, + { name: 'DevOps', value: 20, description: 'Kubernetes, Terraform, CI/CD' }, + ], + title: 'Team Composition', + subtitle: 'Hover for details', + showLegend: true, + valueFormatter: (v: number) => `${v}%`, + }, +}; diff --git a/src/components/Charts/StackedBar/StackedBar.tsx b/src/components/Charts/StackedBar/StackedBar.tsx new file mode 100644 index 000000000..da31c03ee --- /dev/null +++ b/src/components/Charts/StackedBar/StackedBar.tsx @@ -0,0 +1,367 @@ +import { type JSX, useMemo, forwardRef, useCallback, useState } from 'react'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import { BarChart as EChartsBar } from 'echarts/charts'; +import { GridComponent, TooltipComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; +import clsx from 'clsx'; +import { useThemeColors } from '@/hooks/useThemeColors'; +import { getDataVizColors } from '@/utils/dataVizColors'; +import type { StackedBarProps } from './StackedBar.types'; + +// Get data visualization colors once at module level +const { CHART_CATEGORICAL_COLORS } = getDataVizColors(); + +// Register ECharts components +echarts.use([EChartsBar, GridComponent, TooltipComponent, CanvasRenderer]); + +/** + * Default formatter for values (K/M suffix) + */ +function defaultValueFormatter(value: number): string { + if (Math.abs(value) >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (Math.abs(value) >= 1_000) { + return `${(value / 1_000).toFixed(0)}K`; + } + return value.toLocaleString(); +} + +/** + * StackedBar - Horizontal stacked bar for proportional data visualization + * + * A generic component for displaying proportional segments in a horizontal bar. + * Can be used for gas breakdowns, resource allocation, budget distribution, + * or any data that needs proportional visualization. + * + * @example + * ```tsx + * // Gas breakdown example + * + * + * // With interactivity + * console.log('Clicked:', segment.name)} + * selectedSegmentName="Engineering" + * showLegend + * /> + * ``` + */ +export const StackedBar = forwardRef(function StackedBar( + { + segments, + total: providedTotal, + title, + subtitle, + footerLeft, + footerRight, + footerLeftClassName, + footerRightClassName, + showLabels = true, + showPercentages = true, + animated = true, + height = 120, + animationDuration = 300, + valueFormatter = defaultValueFormatter, + showLegend = false, + onSegmentClick, + onSegmentHover, + selectedSegmentName, + minWidthPercent = 0.5, + minLabelWidthPercent = 8, + renderTooltip, + emptyMessage = 'No data available', + }, + ref +): JSX.Element { + const themeColors = useThemeColors(); + const [hoveredSegment, setHoveredSegment] = useState(null); + + // Calculate total from segments if not provided + const total = useMemo(() => { + if (providedTotal !== undefined) return providedTotal; + return segments.reduce((sum, seg) => sum + seg.value, 0); + }, [segments, providedTotal]); + + // Filter segments that meet minimum width threshold + const visibleSegments = useMemo(() => { + if (total === 0) return []; + return segments.filter(seg => { + const pct = (seg.value / total) * 100; + return pct >= minWidthPercent; + }); + }, [segments, total, minWidthPercent]); + + // Build segment color map for legend and consistency + const segmentColors = useMemo(() => { + const colors: Record = {}; + segments.forEach((seg, index) => { + colors[seg.name] = seg.color || CHART_CATEGORICAL_COLORS[index % CHART_CATEGORICAL_COLORS.length]; + }); + return colors; + }, [segments]); + + // Calculate percentage for a segment + const getPercentage = useCallback( + (value: number) => { + return total > 0 ? (value / total) * 100 : 0; + }, + [total] + ); + + // Handle chart events + const handleChartEvents = useMemo(() => { + return { + click: (params: { seriesName: string }) => { + const segment = segments.find(s => s.name === params.seriesName); + if (segment && onSegmentClick) { + onSegmentClick(segment); + } + }, + mouseover: (params: { seriesName: string }) => { + const segment = segments.find(s => s.name === params.seriesName); + setHoveredSegment(params.seriesName); + if (segment && onSegmentHover) { + onSegmentHover(segment); + } + }, + mouseout: () => { + setHoveredSegment(null); + if (onSegmentHover) { + onSegmentHover(null); + } + }, + }; + }, [segments, onSegmentClick, onSegmentHover]); + + const option = useMemo(() => { + if (visibleSegments.length === 0) { + return null; + } + + // Create series data for stacked bar + const seriesData = visibleSegments.map((seg, index) => { + const pct = getPercentage(seg.value); + const isSelected = seg.name === selectedSegmentName; + const isHovered = seg.name === hoveredSegment; + const color = seg.color || CHART_CATEGORICAL_COLORS[index % CHART_CATEGORICAL_COLORS.length]; + + return { + name: seg.name, + type: 'bar' as const, + stack: 'stack', + data: [seg.value], + itemStyle: { + color, + borderColor: isSelected ? themeColors.primary : undefined, + borderWidth: isSelected ? 2 : 0, + opacity: isHovered ? 1 : selectedSegmentName && !isSelected ? 0.6 : 1, + }, + label: + showLabels && pct >= minLabelWidthPercent + ? { + show: true, + position: 'inside' as const, + formatter: () => { + if (showPercentages) { + return `${valueFormatter(seg.value)} (${pct.toFixed(1)}%)`; + } + return valueFormatter(seg.value); + }, + color: '#ffffff', + fontSize: 11, + fontWeight: 500, + } + : { show: false }, + emphasis: { + itemStyle: { + opacity: 1, + }, + }, + cursor: onSegmentClick ? 'pointer' : 'default', + }; + }); + + return { + animation: animated, + animationDuration, + animationEasing: 'cubicOut', + grid: { + left: 16, + right: 16, + top: 8, + bottom: 8, + }, + xAxis: { + type: 'value' as const, + max: total, + show: false, + }, + yAxis: { + type: 'category' as const, + data: [''], + show: false, + }, + series: seriesData, + tooltip: { + trigger: 'item' as const, + backgroundColor: themeColors.background, + borderColor: themeColors.border, + borderWidth: 1, + textStyle: { + color: themeColors.foreground, + fontSize: 12, + }, + formatter: (params: { seriesName: string; value: number; color: string }) => { + const segment = segments.find(s => s.name === params.seriesName); + if (!segment) return ''; + + const pct = getPercentage(params.value); + + // Use custom renderer if provided + if (renderTooltip) { + // For custom tooltip, we need to return HTML string + // This is a limitation - custom renderers return ReactNode but ECharts needs HTML + // For now, fall back to default for custom renderers (they can use onSegmentHover instead) + } + + return ` +
+ + ${params.seriesName} +
+
+ ${params.value.toLocaleString()} (${pct.toFixed(2)}%) +
+ ${segment.description ? `
${segment.description}
` : ''} + `; + }, + }, + }; + }, [ + visibleSegments, + segments, + total, + showLabels, + showPercentages, + valueFormatter, + animated, + animationDuration, + themeColors, + selectedSegmentName, + hoveredSegment, + minLabelWidthPercent, + getPercentage, + onSegmentClick, + renderTooltip, + ]); + + const hasHeader = title || subtitle; + const hasFooter = footerLeft || footerRight; + const legendHeight = showLegend ? 28 : 0; + const chartHeight = height - (hasHeader ? 24 : 0) - (hasFooter ? 24 : 0) - legendHeight; + + // Empty state + if (total === 0 || visibleSegments.length === 0) { + return ( +
+ {hasHeader && ( +
+ {title && {title}} + {subtitle && {subtitle}} +
+ )} +
+ {emptyMessage} +
+
+ ); + } + + return ( +
+ {/* Header */} + {hasHeader && ( +
+ {title && {title}} + {subtitle && {subtitle}} +
+ )} + + {/* Chart */} + {option && ( + + )} + + {/* Footer */} + {hasFooter && ( +
+ {footerLeft && {footerLeft}} + {footerRight && {footerRight}} +
+ )} + + {/* Legend */} + {showLegend && ( +
+ {segments.map(seg => { + const pct = getPercentage(seg.value); + if (pct < minWidthPercent) return null; + + const isSelected = seg.name === selectedSegmentName; + const color = segmentColors[seg.name]; + + return ( + + ); + })} +
+ )} +
+ ); +}); diff --git a/src/components/Charts/StackedBar/StackedBar.types.ts b/src/components/Charts/StackedBar/StackedBar.types.ts new file mode 100644 index 000000000..2d45f8d59 --- /dev/null +++ b/src/components/Charts/StackedBar/StackedBar.types.ts @@ -0,0 +1,157 @@ +import type { ReactNode } from 'react'; + +/** + * A segment in the stacked bar + */ +export interface StackedBarSegment { + /** + * Unique name for this segment (used for selection/callbacks) + */ + name: string; + + /** + * Value of this segment + */ + value: number; + + /** + * Optional color override (uses theme colors by default) + */ + color?: string; + + /** + * Optional description shown in tooltip + */ + description?: string; +} + +/** + * Props for the StackedBar component + * + * A horizontal stacked bar chart for visualizing proportional data. + * Generic component that can be used for gas breakdowns, resource allocation, + * budget distribution, or any proportional visualization. + */ +export interface StackedBarProps { + /** + * Array of segments to display in the bar + */ + segments: StackedBarSegment[]; + + /** + * Optional total value (if different from sum of segments, e.g., for showing remainder) + * If not provided, total is calculated from segments + */ + total?: number; + + /** + * Title displayed above the bar + */ + title?: string; + + /** + * Subtitle/summary displayed on the right of the title + */ + subtitle?: string; + + /** + * Footer text displayed below the bar (left side) + */ + footerLeft?: string; + + /** + * Footer text displayed below the bar (right side) + */ + footerRight?: string; + + /** + * CSS class for footer left text (e.g., 'text-warning', 'text-success') + */ + footerLeftClassName?: string; + + /** + * CSS class for footer right text + */ + footerRightClassName?: string; + + /** + * Show value labels on segments + * @default true + */ + showLabels?: boolean; + + /** + * Show percentage in labels + * @default true + */ + showPercentages?: boolean; + + /** + * Enable entry animation + * @default true + */ + animated?: boolean; + + /** + * Height of the component in pixels (includes header/footer) + * @default 120 + */ + height?: number; + + /** + * Animation duration in milliseconds + * @default 300 + */ + animationDuration?: number; + + /** + * Custom formatter for segment values + * @default Formats with K/M suffix + */ + valueFormatter?: (value: number) => string; + + /** + * Show legend below the bar + * @default false + */ + showLegend?: boolean; + + /** + * Callback when a segment is clicked + */ + onSegmentClick?: (segment: StackedBarSegment) => void; + + /** + * Callback when hovering over a segment (null when leaving) + */ + onSegmentHover?: (segment: StackedBarSegment | null) => void; + + /** + * Name of the currently selected segment (for highlighting) + */ + selectedSegmentName?: string; + + /** + * Minimum percentage width to render a segment (hides very small segments) + * @default 0.5 + */ + minWidthPercent?: number; + + /** + * Minimum percentage width to show labels on a segment + * @default 8 + */ + minLabelWidthPercent?: number; + + /** + * Custom tooltip renderer + * If provided, replaces the default tooltip content + */ + renderTooltip?: (segment: StackedBarSegment, percentage: number) => ReactNode; + + /** + * Message to display when there are no segments or all values are zero + * @default "No data available" + */ + emptyMessage?: string; +} diff --git a/src/components/Charts/StackedBar/index.ts b/src/components/Charts/StackedBar/index.ts new file mode 100644 index 000000000..5a5836f5b --- /dev/null +++ b/src/components/Charts/StackedBar/index.ts @@ -0,0 +1,2 @@ +export { StackedBar } from './StackedBar'; +export type { StackedBarProps, StackedBarSegment } from './StackedBar.types';