datajunction-ui
Version:
DataJunction UI
390 lines (352 loc) • 10.8 kB
JSX
import { useMemo, useEffect, useCallback } from 'react';
import ReactFlow, {
Background,
Controls,
MarkerType,
useNodesState,
useEdgesState,
Handle,
Position,
} from 'reactflow';
import dagre from 'dagre';
import 'reactflow/dist/style.css';
/**
* Compact Pre-aggregation node - clickable, shows minimal info
*/
function PreAggNode({ data, selected }) {
const componentCount = data.components?.length || 0;
return (
<div
className={`compact-node compact-node-preagg ${
selected ? 'selected' : ''
}`}
>
<div className="compact-node-icon">◫</div>
<div className="compact-node-content">
<div className="compact-node-name">{data.name}</div>
<div className="compact-node-meta">
<span className="meta-item">{componentCount} components</span>
{data.grain?.length > 0 && (
<span className="meta-item grain-count">
{data.grain.length} grain cols
</span>
)}
</div>
</div>
<Handle type="source" position={Position.Right} />
</div>
);
}
/**
* Compact Metric node - clickable, shows minimal info
*/
function MetricNode({ data, selected }) {
return (
<div
className={`compact-node compact-node-metric ${
data.isDerived ? 'compact-node-derived' : ''
} ${selected ? 'selected' : ''}`}
>
<Handle type="target" position={Position.Left} />
<div className="compact-node-icon">{data.isDerived ? '◇' : '◈'}</div>
<div className="compact-node-content">
<div className="compact-node-name">{data.shortName}</div>
{data.isDerived && <div className="compact-node-badge">Derived</div>}
</div>
</div>
);
}
/**
* Component node - shows metric building blocks (e.g., SUM, COUNT)
*/
function ComponentNode({ data, selected }) {
return (
<div
className={`compact-node compact-node-component ${
selected ? 'selected' : ''
}`}
>
<Handle type="target" position={Position.Left} />
<div className="compact-node-icon">●</div>
<div className="compact-node-content">
<div className="compact-node-name">{data.shortName}</div>
<div className="compact-node-meta">
<span className="meta-item">{data.aggregation || 'RAW'}</span>
</div>
</div>
<Handle type="source" position={Position.Right} />
</div>
);
}
const nodeTypes = {
preagg: PreAggNode,
component: ComponentNode,
metric: MetricNode,
};
// Node dimensions for dagre layout
const NODE_WIDTH = 200;
const NODE_HEIGHT = 50;
/**
* Use dagre to automatically layout nodes
*/
function getLayoutedElements(nodes, edges) {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
// Configure the layout
dagreGraph.setGraph({
rankdir: 'LR', // Left to right
nodesep: 60, // Vertical spacing between nodes
ranksep: 150, // Horizontal spacing between columns
marginx: 40,
marginy: 40,
});
// Add nodes to dagre
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
// Add edges to dagre
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target);
});
// Run the layout
dagre.layout(dagreGraph);
// Apply the calculated positions back to nodes
const layoutedNodes = nodes.map(node => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
};
});
return { nodes: layoutedNodes, edges };
}
/**
* MetricFlowGraph - Uses dagre for automatic layout
*/
export function MetricFlowGraph({
grainGroups,
metricFormulas,
selectedNode,
onNodeSelect,
}) {
const { nodes, edges } = useMemo(() => {
if (!grainGroups?.length || !metricFormulas?.length) {
return { nodes: [], edges: [] };
}
const rawNodes = [];
const rawEdges = [];
// Track mappings
const preAggNodesMap = new Map();
const componentNodeIds = new Map();
const componentToPreAgg = new Map();
let nodeId = 0;
const getNextId = () => `node-${nodeId++}`;
// Build component -> preAgg mapping
grainGroups.forEach((gg, idx) => {
gg.components?.forEach(comp => {
componentToPreAgg.set(comp.name, idx);
});
});
// Create pre-aggregation nodes
grainGroups.forEach((gg, idx) => {
const id = getNextId();
preAggNodesMap.set(idx, id);
const shortName = gg.parent_name?.split('.').pop() || `preagg_${idx}`;
rawNodes.push({
id,
type: 'preagg',
position: { x: 0, y: 0 }, // Will be set by dagre
data: {
name: shortName,
fullName: gg.parent_name,
grain: gg.grain || [],
components: gg.components || [],
grainGroupIndex: idx,
},
selected:
selectedNode?.type === 'preagg' && selectedNode?.index === idx,
});
});
// Create component nodes (building blocks for metrics)
grainGroups.forEach((gg, ggIdx) => {
gg.components?.forEach(comp => {
if (!componentNodeIds.has(comp.name)) {
const id = getNextId();
componentNodeIds.set(comp.name, id);
// Shorten name for display (e.g., "unit_price_sum" -> "price_sum")
const shortName =
comp.name.length > 40
? '...' + comp.name.split('_').slice(-2).join('_')
: comp.name;
rawNodes.push({
id,
type: 'component',
position: { x: 0, y: 0 },
data: {
name: comp.name,
shortName,
aggregation: comp.aggregation,
merge: comp.merge,
grainGroupIndex: ggIdx,
},
selected:
selectedNode?.type === 'component' &&
selectedNode?.name === comp.name,
});
}
});
});
// Create metric nodes
const metricNodeIds = new Map();
metricFormulas.forEach((metric, idx) => {
const id = getNextId();
metricNodeIds.set(metric.name, id);
rawNodes.push({
id,
type: 'metric',
position: { x: 0, y: 0 }, // Will be set by dagre
data: {
name: metric.name,
shortName: metric.short_name,
combiner: metric.combiner,
isDerived: metric.is_derived,
components: metric.components,
metricIndex: idx,
},
selected:
selectedNode?.type === 'metric' && selectedNode?.index === idx,
});
});
// Create edges: PreAgg -> Component
grainGroups.forEach((gg, ggIdx) => {
const preAggId = preAggNodesMap.get(ggIdx);
gg.components?.forEach(comp => {
const compId = componentNodeIds.get(comp.name);
if (preAggId && compId) {
rawEdges.push({
id: `edge-preagg-${preAggId}-${compId}`,
source: preAggId,
target: compId,
style: { stroke: '#64748b', strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#64748b',
width: 16,
height: 16,
},
});
}
});
});
// Create edges: Component -> Metric
metricFormulas.forEach(metric => {
const metricId = metricNodeIds.get(metric.name);
metric.components?.forEach(compName => {
const compId = componentNodeIds.get(compName);
if (compId && metricId) {
rawEdges.push({
id: `edge-comp-${compId}-${metricId}`,
source: compId,
target: metricId,
style: { stroke: '#64748b', strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#64748b',
width: 16,
height: 16,
},
});
}
});
});
// Apply dagre layout
return getLayoutedElements(rawNodes, rawEdges);
}, [grainGroups, metricFormulas, selectedNode]);
const [flowNodes, setNodes, onNodesChange] = useNodesState(nodes);
const [flowEdges, setEdges, onEdgesChange] = useEdgesState(edges);
// Update nodes/edges when data changes
useEffect(() => {
setNodes(nodes);
setEdges(edges);
}, [nodes, edges, setNodes, setEdges]);
const handleNodeClick = useCallback(
(event, node) => {
if (node.type === 'preagg') {
onNodeSelect?.({
type: 'preagg',
index: node.data.grainGroupIndex,
data: grainGroups[node.data.grainGroupIndex],
});
} else if (node.type === 'component') {
onNodeSelect?.({
type: 'component',
name: node.data.name,
data: node.data,
});
} else if (node.type === 'metric') {
onNodeSelect?.({
type: 'metric',
index: node.data.metricIndex,
data: metricFormulas[node.data.metricIndex],
});
}
},
[onNodeSelect, grainGroups, metricFormulas],
);
const handlePaneClick = useCallback(() => {
onNodeSelect?.(null);
}, [onNodeSelect]);
if (!grainGroups?.length || !metricFormulas?.length) {
return (
<div className="graph-empty-state">
<div className="empty-icon">◎</div>
<p>Select metrics and dimensions above to visualize the data flow</p>
</div>
);
}
return (
<div className="compact-flow-container">
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
onNodeClick={handleNodeClick}
onPaneClick={handlePaneClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.5}
maxZoom={1.5}
attributionPosition="bottom-left"
proOptions={{ hideAttribution: true }}
>
<Background color="#cbd5e1" gap={20} size={1} />
<Controls showInteractive={false} />
</ReactFlow>
{/* Legend */}
<div className="graph-legend">
<div className="legend-item">
<span className="legend-dot preagg"></span>
<span>Pre-agg</span>
</div>
<div className="legend-item">
<span className="legend-dot component"></span>
<span>Component</span>
</div>
<div className="legend-item">
<span className="legend-dot metric"></span>
<span>Metric</span>
</div>
<div className="legend-item">
<span className="legend-dot derived"></span>
<span>Derived</span>
</div>
</div>
</div>
);
}
export default MetricFlowGraph;