UNPKG

datajunction-ui

Version:
465 lines (436 loc) 14.4 kB
import { useContext, useEffect, useRef, useState } from 'react'; import * as React from 'react'; import { Sankey, Tooltip } from 'recharts'; import { useNavigate } from 'react-router-dom'; import DJClientContext from '../../providers/djclient'; import LoadingIcon from '../../icons/LoadingIcon'; // Match badge background colors from index.css .node_type__* classes const TYPE_COLORS = { source: '#ccf7e5', transform: '#ccefff', metric: '#fad7dd', dimension: '#ffefd0', cube: '#dbafff', }; const TYPE_BORDER_COLORS = { source: '#00b368', transform: '#0063b4', metric: '#a2283e', dimension: '#a96621', cube: '#580076', }; const TYPE_LAYER_ORDER = ['source', 'transform', 'dimension', 'metric', 'cube']; // Returns a slightly darker version of the pastel fill for the node border const DARKER_FILL = { source: '#8de8c3', transform: '#8ed6f7', metric: '#f0a3b0', dimension: '#ffd08a', cube: '#bc80f5', }; function SankeyNode({ x, y, width, height, payload, currentNodeName, rightmostType, onNavigate, hoveredNodeName, onNodeHover, }) { if (!payload) return null; if (payload.type === 'phantom') return <g />; const isHovered = hoveredNodeName === payload.name; const isDimmed = hoveredNodeName && !isHovered; const baseFill = TYPE_COLORS[payload.type] ?? '#f1f5f9'; const hoverFill = DARKER_FILL[payload.type] ?? '#cbd5e1'; const borderColor = DARKER_FILL[payload.type] ?? '#cbd5e1'; const isCurrent = payload.name === currentNodeName; const label = (payload.display_name || payload.name || '').split('.').pop(); const isRightmost = payload.type === rightmostType; const labelX = isRightmost ? x + width + 8 : x - 8; const labelAnchor = isRightmost ? 'start' : 'end'; return ( <g style={{ cursor: payload.name ? 'pointer' : 'default', opacity: isDimmed ? 0.55 : 1, transition: 'opacity 0.15s', }} onMouseEnter={() => onNodeHover && onNodeHover(payload.name)} onMouseLeave={() => onNodeHover && onNodeHover(null)} onClick={() => payload.name && onNavigate && onNavigate('/nodes/' + payload.name) } > <rect x={x} y={y} width={width} height={height} fill={isHovered ? hoverFill : baseFill} fillOpacity={1} stroke={isCurrent || isHovered ? borderColor : 'none'} strokeWidth={1.5} rx={2} /> <text x={labelX} y={y + height / 2} textAnchor={labelAnchor} dominantBaseline="middle" fontSize={11} fill="#374151" style={{ userSelect: 'none', pointerEvents: 'none' }} > {label} </text> </g> ); } function SankeyLink({ sourceX, targetX, sourceY, targetY, sourceControlX, targetControlX, linkWidth, index, payload, hoveredNodeName, }) { const [linkHovered, setLinkHovered] = useState(false); const hw = Math.max(linkWidth, 1); const d = ` M${sourceX},${sourceY - hw / 2} C${sourceControlX},${sourceY - hw / 2} ${targetControlX},${ targetY - hw / 2 } ${targetX},${targetY - hw / 2} L${targetX},${targetY + hw / 2} C${targetControlX},${targetY + hw / 2} ${sourceControlX},${ sourceY + hw / 2 } ${sourceX},${sourceY + hw / 2} Z `; const targetType = payload?.target?.type; if (targetType === 'phantom') return <g />; const sourceType = payload?.source?.type; const fromColor = TYPE_COLORS[sourceType] ?? '#e2e8f0'; const toColor = TYPE_COLORS[targetType] ?? '#e2e8f0'; const gradientId = `link-grad-${index}`; const isConnected = hoveredNodeName && (payload?.source?.name === hoveredNodeName || payload?.target?.name === hoveredNodeName); const opacity = linkHovered ? 0.85 : hoveredNodeName ? isConnected ? 0.8 : 0.15 : 0.38; return ( <g> <defs> <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%"> <stop offset="0%" stopColor={fromColor} stopOpacity={opacity} /> <stop offset="100%" stopColor={toColor} stopOpacity={opacity} /> </linearGradient> </defs> <path d={d} fill={`url(#${gradientId})`} stroke="none" style={{ cursor: 'pointer', transition: 'opacity 0.15s' }} onMouseEnter={() => setLinkHovered(true)} onMouseLeave={() => setLinkHovered(false)} /> </g> ); } export default function NodeDataFlowTab({ djNode }) { const djClient = useContext(DJClientContext).DataJunctionAPI; const navigate = useNavigate(); const [sankeyData, setSankeyData] = useState(null); const [loading, setLoading] = useState(true); const [containerWidth, setContainerWidth] = useState(0); const [hoveredNodeName, setHoveredNodeName] = useState(null); const containerRef = useRef(null); useEffect(() => { const el = containerRef.current; if (!el) return; // Read initial width synchronously so the chart fills the container on first paint setContainerWidth(el.getBoundingClientRect().width); const observer = new ResizeObserver(entries => { setContainerWidth(entries[0].contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); useEffect(() => { if (!djNode?.name) return; setLoading(true); Promise.all([ djClient.upstreamsGQL(djNode.name), djClient.downstreamsGQL(djNode.name), ]) .then(async ([upstreamNodes, downstreamNodes]) => { const normalize = n => ({ ...n, type: n.type?.toLowerCase() }); const upstream = (upstreamNodes || []).map(normalize); const downstream = (downstreamNodes || []).map(normalize); // Fetch downstream cubes in one batch call const cubeNames = downstream .filter(n => n.type === 'cube') .map(n => n.name); const cubeNodes = await djClient.findCubesWithMetrics(cubeNames); const nonCubeDownstreams = downstream.filter(n => n.type !== 'cube'); const allNodes = [ djNode, ...upstream, ...cubeNodes, ...nonCubeDownstreams, ]; // Deduplicate nodes const seen = new Set(); const nodes = []; allNodes.forEach(n => { if (n && !seen.has(n.name)) { seen.add(n.name); nodes.push(n); } }); // Sort so seed node is first within its type group — it will appear at the top // of its column when sort={false} is used. const seedName = djNode?.name; nodes.sort((a, b) => { const aLayer = TYPE_LAYER_ORDER.indexOf(a.type); const bLayer = TYPE_LAYER_ORDER.indexOf(b.type); if (aLayer !== bLayer) return aLayer - bLayer; if (a.name === seedName) return -1; if (b.name === seedName) return 1; return 0; }); const nodeIndex = {}; nodes.forEach((n, i) => { nodeIndex[n.name] = i; }); const links = []; nodes.forEach(node => { (node.current?.parents || node.parents || []).forEach(parent => { if ( parent.name && nodeIndex[parent.name] !== undefined && nodeIndex[node.name] !== undefined ) { links.push({ source: nodeIndex[parent.name], target: nodeIndex[node.name], value: 1, }); } }); }); // recharts forces any node with no outgoing links to maxDepth (the cube column). // Fix: give free-floating metrics a tiny phantom outgoing link so they stay in // the metric column. The phantom node renders as invisible. const hasOutgoing = new Set(links.map(l => l.source)); const phantomLinks = []; nodes.forEach((node, i) => { if (node.type === 'metric' && !hasOutgoing.has(i)) { phantomLinks.push({ source: i, target: nodes.length, value: 0.01 }); } }); if (phantomLinks.length > 0) { nodes.push({ name: '__phantom__', type: 'phantom', display_name: '', }); links.push(...phantomLinks); } setSankeyData({ nodes, links }); setLoading(false); }) .catch(err => { console.error(err); setLoading(false); }); }, [djNode, djClient]); // Always render the sentinel div so containerRef is mounted before data loads if (loading || !sankeyData || sankeyData.links.length === 0) { return ( <div> {/* Sentinel must be in DOM so ResizeObserver fires even during loading */} <div ref={containerRef} style={{ width: '100%', height: 0 }} /> {loading ? ( <div style={{ padding: '2rem' }}> <LoadingIcon /> </div> ) : ( <div style={{ padding: '2rem', color: '#64748b', fontSize: 14 }}> No data flow relationships found for this node. </div> )} </div> ); } const counts = sankeyData.nodes.reduce((acc, n) => { if (n.type !== 'phantom') acc[n.type] = (acc[n.type] || 0) + 1; return acc; }, {}); const summaryParts = TYPE_LAYER_ORDER.filter(t => counts[t]).map( t => `${counts[t]} ${t}${counts[t] > 1 ? 's' : ''}`, ); // Height driven by the tallest column, not total node count const colDepths = {}; sankeyData.nodes.forEach(n => { if (n.type !== 'phantom') { const col = TYPE_LAYER_ORDER.indexOf(n.type); colDepths[col] = (colDepths[col] || 0) + 1; } }); const maxColNodes = Math.max(...Object.values(colDepths), 1); // Derive chart height so each node is at least MIN_NODE_HEIGHT px tall. // recharts fills: chartHeight - top - bottom = maxNodes * nodeHeight + (maxNodes - 1) * nodePadding // Solving for chartHeight given a desired minimum nodeHeight: const MIN_NODE_HEIGHT = 24; const NODE_PADDING = 12; const MARGIN_V = 20; // top + bottom margin const chartHeight = Math.max( 280, maxColNodes * (MIN_NODE_HEIGHT + NODE_PADDING) - NODE_PADDING + MARGIN_V, ); // Rightmost column determines label side (right); everything else labels left const rightmostType = TYPE_LAYER_ORDER.slice() .reverse() .find(t => counts[t]) ?? 'metric'; // Measure label widths per side to set margins const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = '11px system-ui, sans-serif'; const measureLabel = n => ctx.measureText((n.display_name || n.name || '').split('.').pop()).width; const rightNodes = sankeyData.nodes.filter(n => n.type === rightmostType); const leftNodes = sankeyData.nodes.filter( n => n.type !== rightmostType && n.type !== 'phantom', ); const rightMargin = Math.ceil(Math.max(0, ...rightNodes.map(measureLabel))) + 16; const leftMargin = Math.ceil(Math.max(0, ...leftNodes.map(measureLabel))) + 16; const nodeEl = ( <SankeyNode currentNodeName={djNode?.name} rightmostType={rightmostType} onNavigate={navigate} hoveredNodeName={hoveredNodeName} onNodeHover={setHoveredNodeName} /> ); const linkEl = <SankeyLink hoveredNodeName={hoveredNodeName} />; return ( <div style={{ padding: '1.5rem 0.75rem' }}> <div style={{ fontSize: 13, fontWeight: 700, color: '#374151', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '0.75rem', }} > {summaryParts.join(' → ')} </div> <div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap', }} > {TYPE_LAYER_ORDER.filter(t => counts[t]).map(type => ( <span key={type} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: '#64748b', }} > <span style={{ width: 12, height: 12, background: TYPE_COLORS[type], border: `1.5px solid ${TYPE_BORDER_COLORS[type]}`, borderRadius: 2, display: 'inline-block', }} /> {type} </span> ))} </div> {/* Sentinel div: measures true available width without the Sankey influencing it */} <div ref={containerRef} style={{ width: '100%', height: 0 }} /> {containerWidth > 0 && ( <Sankey width={containerWidth} height={chartHeight} data={sankeyData} nodePadding={NODE_PADDING} nodeWidth={16} margin={{ top: 10, right: rightMargin, bottom: 10, left: leftMargin }} node={nodeEl} link={linkEl} sort={false} > <Tooltip content={({ active, payload }) => { if (!active || !payload?.length) return null; const item = payload[0]?.payload; if (!item) return null; const name = item.name || `${item.source?.display_name || item.source?.name} → ${ item.target?.display_name || item.target?.name }`; return ( <div style={{ background: 'white', border: '1px solid #e2e8f0', padding: '8px 12px', borderRadius: 4, fontSize: 12, boxShadow: '0 2px 8px rgba(0,0,0,0.1)', }} > <div style={{ fontWeight: 600 }}>{name}</div> {item.type && ( <div style={{ color: TYPE_COLORS[item.type] || '#64748b', marginTop: 2, }} > {item.type} </div> )} </div> ); }} /> </Sankey> )} </div> ); }