Last active
March 21, 2025 20:10
-
-
Save PoisonAlien/1833a335f607710044b00a51e2557358 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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