Skip to content

Instantly share code, notes, and snippets.

@PoisonAlien
Last active March 21, 2025 20:10
Show Gist options
  • Save PoisonAlien/1833a335f607710044b00a51e2557358 to your computer and use it in GitHub Desktop.
Save PoisonAlien/1833a335f607710044b00a51e2557358 to your computer and use it in GitHub Desktop.
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChevronRight, ChevronDown } from 'lucide-react';
interface NodeData {
name: string;
type?: string;
details?: string;
children?: NodeData[];
_children?: NodeData[]; // For collapsed nodes storage
}
// Create a custom interface that extends d3's HierarchyNode
interface CustomHierarchyNode<T> extends d3.HierarchyNode<T> {
_children?: d3.HierarchyNode<T>[];
x0?: number;
y0?: number;
id?: string;
}
interface PdfDeconstructProps {
pdfData: any;
}
const PdfDeconstruct: React.FC<PdfDeconstructProps> = ({ pdfData }) => {
const svgRef = useRef<SVGSVGElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [selectedNode, setSelectedNode] = useState<any | null>(null);
const [showRelationships, setShowRelationships] = useState<boolean>(true);
const [treeData, setTreeData] = useState<NodeData | null>(null);
useEffect(() => {
if (!pdfData?.hierarchicalData) return;
setTreeData(JSON.parse(JSON.stringify(pdfData.hierarchicalData)));
}, [pdfData]);
useEffect(() => {
if (!treeData || !svgRef.current) return;
// Clear previous SVG content only at initialization
d3.select(svgRef.current).selectAll("*").remove();
// Set up dimensions
const width = 960;
const height = 700;
const margin = { top: 40, right: 120, bottom: 50, left: 120 };
// Create the SVG container
const svg = d3.select(svgRef.current)
.attr("width", "100%")
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;");
// Define the tree layout
const tree = d3.tree<NodeData>()
.size([height - margin.top - margin.bottom, width - margin.left - margin.right])
.separation((a, b) => (a.parent === b.parent ? 1 : 1.2));
// Create root group for translation
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Create a hierarchy from treeData
const root = d3.hierarchy<NodeData>(treeData, d => d.children) as CustomHierarchyNode<NodeData>;
// Assign IDs to nodes for stable identification
let nodeId = 0;
root.descendants().forEach(d => {
(d as CustomHierarchyNode<NodeData>).id = `node-${nodeId++}`;
});
// Store initial positions
root.x0 = height / 2;
root.y0 = 0;
// Helper function to collapse node
function collapse(d: CustomHierarchyNode<NodeData>) {
if (d.children) {
d._children = d.children;
d._children.forEach(collapse);
d.children = undefined;
}
}
// Helper function to expand node
function expand(d: CustomHierarchyNode<NodeData>) {
if (d._children) {
d.children = d._children;
d._children = undefined;
}
}
// Collapse all nodes except the root initially
root.children?.forEach(collapse);
// Helper function to toggle node collapse
function toggleNode(d: CustomHierarchyNode<NodeData>) {
if (d.children) {
d._children = d.children;
d.children = undefined;
} else if (d._children) {
d.children = d._children;
d._children = undefined;
}
update(d);
}
// Define node type color mapping
const typeColor = (type: string | undefined) => {
if (!type) return "#E0E0E0";
const typeLower = type.toLowerCase();
if (typeLower.includes("background") || typeLower.includes("introduction")) return "#90CAF9"; // blue
if (typeLower.includes("method")) return "#A5D6A7"; // green
if (typeLower.includes("result")) return "#FFE082"; // amber
if (typeLower.includes("discussion") || typeLower.includes("conclusion")) return "#CE93D8"; // purple
return "#E0E0E0"; // gray default
};
// Function to update the tree visualization
function update(source: CustomHierarchyNode<NodeData>) {
// Duration for transitions
const duration = 500;
// Compute the new tree layout
tree(root);
const nodes = root.descendants();
const links = root.links();
// Set fixed distance from the root for all nodes at the same depth
nodes.forEach(d => {
d.y = d.depth * 180; // Horizontal spacing by depth
});
// Update the nodes...
const node = g.selectAll<SVGGElement, CustomHierarchyNode<NodeData>>('g.node')
.data(nodes, d => d.id || "");
// Enter any new nodes at the parent's previous position
const nodeEnter = node.enter().append('g')
.attr('class', d => `node ${d.children || d._children ? "node--internal" : "node--leaf"}`)
.attr('transform', d => `translate(${source.y0},${source.x0})`)
.attr('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation();
toggleNode(d);
setSelectedNode(selectedNode && selectedNode.name === d.data.name ? null : d.data);
})
.on("mouseover", function(event, d) {
// Show tooltip
d3.select(tooltipRef.current)
.style("visibility", "visible")
.html(() => {
let content = `<div style="font-weight: bold; margin-bottom: 8px;">${d.data.name}</div>`;
if (d.data.type) {
content += `<div style="background-color: #f0f0f0; display: inline-block; padding: 3px 8px; border-radius: 4px; margin-bottom: 8px; font-size: 12px;">${d.data.type}</div>`;
}
if (d.data.details) {
content += `<div style="margin-top: 8px; color: #333;">${d.data.details}</div>`;
}
// Add statistical evidence if available
const stats = pdfData.statisticalEvidence?.find(
(stat: any) => stat.nodeId === d.data.name
);
if (stats) {
content += `<div style="margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px;">
<div style="font-weight: 500; margin-bottom: 5px;">Statistical Evidence:</div>`;
if (stats.testType) content += `<div>Test: <span style="color: #333;">${stats.testType}</span></div>`;
if (stats.pValue) content += `<div>p-value: <span style="color: #333;">${stats.pValue}</span></div>`;
if (stats.sampleSize) content += `<div>Sample size: <span style="color: #333;">${stats.sampleSize}</span></div>`;
if (stats.effectSize) content += `<div>Effect size: <span style="color: #333;">${stats.effectSize}</span></div>`;
content += `</div>`;
}
// Add figure references if available
const figureRefs = Object.entries(pdfData.figureReferences || {})
.filter(([_, figData]: [string, any]) =>
figData.referencingNodes?.includes(d.data.name)
);
if (figureRefs.length > 0) {
content += `<div style="margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px;">
<div style="font-weight: 500; margin-bottom: 5px;">Referenced Figures:</div>
<div style="display: flex; flex-wrap: wrap; gap: 5px;">`;
figureRefs.forEach(([figId, figData]: [string, any]) => {
content += `<div style="background-color: #90CAF9; padding: 2px 6px; border-radius: 4px; font-size: 12px;">${figId.toUpperCase()}</div>`;
if (figData.caption) {
content += `<div style="width: 100%; margin-top: 4px; font-size: 11px; color: #555;">${figData.caption.substring(0, 100)}${figData.caption.length > 100 ? '...' : ''}</div>`;
}
// Add panel information if available
if (figData.panels && figData.panels.length > 0) {
content += `<div style="width: 100%; margin-top: 4px; font-size: 11px; color: #555;">Panels: ${figData.panels.join(', ')}</div>`;
}
});
content += `</div></div>`;
}
return content;
});
// Position tooltip
const tooltipElement = tooltipRef.current;
if (tooltipElement) {
const svgRect = svgRef.current?.getBoundingClientRect();
if (svgRect) {
const nodeX = svgRect.left + d.y + margin.left;
const nodeY = svgRect.top + d.x + margin.top;
tooltipElement.style.left = `${nodeX + 15}px`;
tooltipElement.style.top = `${nodeY - 10}px`;
}
}
// Highlight node
d3.select(this).select("circle")
.transition()
.duration(200)
.attr("r", 6)
.attr("stroke-width", 2)
.attr("stroke", "#3B82F6");
})
.on("mouseout", function() {
d3.select(tooltipRef.current).style("visibility", "hidden");
// Restore node appearance
d3.select(this).select("circle")
.transition()
.duration(200)
.attr("r", 5)
.attr("stroke-width", 1.5)
.attr("stroke", "#555");
});
// Add circles to nodes
nodeEnter.append('circle')
.attr('r', 0) // Start with radius 0 for animation
.attr('fill', d => typeColor(d.data.type))
.attr('stroke', '#555')
.attr('stroke-width', 1.5)
.transition()
.duration(duration)
.attr('r', 5);
// Add expand/collapse indicators
nodeEnter.each(function(d) {
if (d._children || d.children) {
d3.select(this)
.append("text")
.attr("dy", 3)
.attr("x", -10)
.attr("text-anchor", "end")
.attr("font-family", "sans-serif")
.attr("class", "toggle-icon")
.text(d.children ? "−" : "+")
.attr("font-size", "12px");
}
});
// Add figure badge for nodes with figure references
nodeEnter.each(function(d) {
const hasFigureRef = Object.values(pdfData.figureReferences || {}).some(
(fig: any) => fig.referencingNodes?.includes(d.data.name)
);
if (hasFigureRef) {
d3.select(this)
.append("text")
.attr("x", 15)
.attr("y", -10)
.attr("font-family", "sans-serif")
.attr("class", "figure-badge")
.text("📊")
.attr("font-size", "12px");
}
});
// Add node labels
nodeEnter.append('text')
.attr('dy', 3)
.attr('x', d => d.children || d._children ? -8 : 8)
.attr('text-anchor', d => d.children || d._children ? "end" : "start")
.attr('font-size', '12px')
.attr('font-weight', d => d.depth === 0 ? 'bold' : 'normal')
.attr("class", "node-label")
.text(d => {
const name = d.data.name;
return name.length > 30 ? name.substring(0, 30) + "..." : name;
})
.attr('fill', '#222')
.style('fill-opacity', 0) // Start with opacity 0 for animation
.transition()
.duration(duration)
.style('fill-opacity', 1);
// Update the nodes to their new positions
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition()
.duration(duration)
.attr('transform', d => `translate(${d.y},${d.x})`);
// Update node attributes
nodeUpdate.select('circle')
.attr('r', 5)
.attr('fill', d => typeColor(d.data.type));
// Update toggle icons
nodeUpdate.selectAll('.toggle-icon')
.text(d => d.children ? "−" : "+");
// Update node labels
nodeUpdate.selectAll('.node-label')
.attr('x', d => d.children || d._children ? -8 : 8)
.attr('text-anchor', d => d.children || d._children ? "end" : "start");
// Remove exiting nodes with transition
const nodeExit = node.exit().transition()
.duration(duration)
.attr('transform', d => `translate(${source.y},${source.x})`)
.remove();
nodeExit.select('circle')
.attr('r', 0);
nodeExit.select('text')
.style('fill-opacity', 0);
// Update the links
const link = g.selectAll('path.link')
.data(links, d => (d.target as CustomHierarchyNode<NodeData>).id || "");
// Enter any new links at the parent's previous position
const linkEnter = link.enter().append('path')
.attr('class', 'link')
.attr('d', d => {
const o = { x: source.x0 || 0, y: source.y0 || 0 };
return `M${o.y},${o.x}
C${(o.y + o.y) / 2},${o.x}
${(o.y + o.y) / 2},${o.x}
${o.y},${o.x}`;
})
.attr('fill', 'none')
.attr('stroke', '#AEAEAE')
.attr('stroke-width', 1.5)
.style('opacity', 0) // Start with opacity 0 for animation
.transition()
.duration(duration)
.style('opacity', 1);
// Transition links to their new positions
const linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(duration)
.attr('d', d => {
return `M${d.source.y},${d.source.x}
C${(d.source.y + d.target.y) / 2},${d.source.x}
${(d.source.y + d.target.y) / 2},${d.target.x}
${d.target.y},${d.target.x}`;
});
// Remove exiting links with transition
link.exit().transition()
.duration(duration)
.style('opacity', 0)
.attr('d', d => {
const o = { x: source.x, y: source.y };
return `M${o.y},${o.x}
C${(o.y + o.y) / 2},${o.x}
${(o.y + o.y) / 2},${o.x}
${o.y},${o.x}`;
})
.remove();
// Add relationships if enabled
if (showRelationships && pdfData) {
// Remove any existing relationships
g.selectAll(".relationship-link").remove();
const relationships = [
...(pdfData.resultRelationships || []),
...(pdfData.crossSectionRelationships || [])
];
if (relationships.length > 0) {
// Create a mapping of node names to positions
const nodeMap = new Map();
nodes.forEach(node => {
if (node && node.data) {
nodeMap.set(node.data.name, { x: node.x, y: node.y });
}
});
// Add relationship connections
relationships.forEach(rel => {
const sourceNode = nodeMap.get(rel.source);
const targetNode = nodeMap.get(rel.target);
if (sourceNode && targetNode) {
// Determine line style based on relationship type
let dashArray = "none";
let strokeColor = "#E91E63"; // Pink for default
if (rel.type?.toLowerCase().includes("causal")) {
dashArray = "none";
} else if (rel.type?.toLowerCase().includes("correlat")) {
dashArray = "5,5";
} else if (rel.type?.toLowerCase().includes("support")) {
dashArray = "2,2";
} else if (rel.type?.toLowerCase().includes("contradict")) {
dashArray = "8,4,2,4";
strokeColor = "#FF7043"; // Orange
}
// Create curved path between related nodes
g.append("path")
.attr("d", `M${sourceNode.y},${sourceNode.x} C${(sourceNode.y + targetNode.y) / 2 + 50},${sourceNode.x} ${(sourceNode.y + targetNode.y) / 2 - 50},${targetNode.x} ${targetNode.y},${targetNode.x}`)
.attr("fill", "none")
.attr("stroke", strokeColor)
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", dashArray)
.attr("class", "relationship-link")
.style("opacity", 0) // Start with opacity 0 for animation
.transition()
.duration(duration)
.style("opacity", 0.7)
.on("end", function() {
d3.select(this).append("title")
.text(rel.type || "Related");
});
}
});
}
}
// Store the old positions for transition
nodes.forEach(d => {
const customD = d as CustomHierarchyNode<NodeData>;
customD.x0 = d.x;
customD.y0 = d.y;
});
}
// Initialize the display
update(root);
// Handle window resize to make visualization responsive
const handleResize = () => {
if (svgRef.current) {
const width = svgRef.current.parentElement?.clientWidth || 960;
const height = 700;
d3.select(svgRef.current)
.attr("width", "100%")
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [treeData, showRelationships, pdfData, selectedNode]);
// Toggle showing relationships
const toggleRelationships = () => {
setShowRelationships(!showRelationships);
};
// Component JSX
return (
<div className="relative space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium">Paper Structure Visualization</h3>
<div className="flex gap-2">
<button
onClick={toggleRelationships}
className={`px-3 py-1.5 rounded-md text-sm border hover:bg-gray-50 flex items-center gap-1 ${showRelationships ? 'bg-blue-50 border-blue-200' : 'bg-white'}`}
>
{showRelationships ? 'Hide Relationships' : 'Show Relationships'}
</button>
</div>
</div>
<div className="border bg-white rounded-lg overflow-hidden">
<div className="relative h-[700px] overflow-auto">
<svg
ref={svgRef}
className="w-full"
/>
<div
ref={tooltipRef}
className="tooltip"
style={{
position: 'fixed',
visibility: 'hidden',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '8px',
padding: '10px',
boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
pointerEvents: 'none',
zIndex: 10,
maxWidth: '300px',
fontSize: '12px'
}}
/>
</div>
<div className="p-4 border-t bg-gray-50">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-[#90CAF9] mr-2 border border-gray-400"></div>
<span>Background/Introduction</span>
</div>
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-[#A5D6A7] mr-2 border border-gray-400"></div>
<span>Methods</span>
</div>
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-[#FFE082] mr-2 border border-gray-400"></div>
<span>Results</span>
</div>
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-[#CE93D8] mr-2 border border-gray-400"></div>
<span>Discussion/Conclusion</span>
</div>
<div className="flex items-center">
<div className="w-8 border-t-2 border-[#888] mr-2"></div>
<span>Hierarchy</span>
</div>
<div className="flex items-center">
<div className="w-8 border-t-2 border-[#E91E63] mr-2"></div>
<span>Causal</span>
</div>
<div className="flex items-center">
<div className="w-8 border-t-2 border-[#E91E63] border-dashed mr-2"></div>
<span>Correlation</span>
</div>
<div className="flex items-center">
<div className="w-8 border-t-2 border-[#E91E63] border-dotted mr-2"></div>
<span>Support</span>
</div>
</div>
</div>
</div>
{selectedNode && (
<Card className="mt-4 animate-fade-in">
<CardHeader className="pb-2">
<CardTitle>Selected Node: {selectedNode.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{selectedNode.details && (
<p className="text-gray-700">{selectedNode.details}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
{/* Statistical Evidence */}
<div>
<h4 className="text-sm font-semibold mb-2">Statistical Evidence</h4>
{pdfData.statisticalEvidence?.some((stat: any) => stat.nodeId === selectedNode.name) ? (
<div className="space-y-2">
{pdfData.statisticalEvidence
.filter((stat: any) => stat.nodeId === selectedNode.name)
.map((stat: any, index: number) => (
<div key={index} className="bg-gray-50 p-3 rounded-md border">
{stat.testType && <div className="text-sm"><span className="font-medium">Test:</span> {stat.testType}</div>}
{stat.pValue && <div className="text-sm"><span className="font-medium">p-value:</span> {stat.pValue}</div>}
{stat.sampleSize && <div className="text-sm"><span className="font-medium">Sample size:</span> {stat.sampleSize}</div>}
{stat.effectSize && <div className="text-sm"><span className="font-medium">Effect size:</span> {stat.effectSize}</div>}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No statistical evidence available</p>
)}
</div>
{/* Related Figures */}
<div>
<h4 className="text-sm font-semibold mb-2">Related Figures</h4>
{Object.entries(pdfData.figureReferences || {})
.filter(([_, figData]: [string, any]) =>
figData.referencingNodes?.includes(selectedNode.name)
).length > 0 ? (
<div className="space-y-2">
{Object.entries(pdfData.figureReferences || {})
.filter(([_, figData]: [string, any]) =>
figData.referencingNodes?.includes(selectedNode.name)
)
.map(([figId, figData]: [string, any]) => (
<div key={figId} className="bg-gray-50 p-3 rounded-md border">
<div className="font-medium">{figId.toUpperCase()}</div>
{figData.caption && <p className="text-sm mt-1">{figData.caption}</p>}
{figData.panels && figData.panels.length > 0 && (
<div className="mt-2">
<span className="text-xs font-medium">Panels:</span>
<div className="flex flex-wrap gap-1 mt-1">
{figData.panels.map((panel: string) => (
<span key={panel} className="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded">
{panel}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No related figures</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
};
export default PdfDeconstruct;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment