Add interactive PedigreeTree component with D3 visualization

This commit is contained in:
2026-03-09 00:40:56 -05:00
parent 320465854e
commit e62c2bcd32

View File

@@ -0,0 +1,167 @@
import { useState, useCallback, useEffect } from 'react'
import Tree from 'react-d3-tree'
import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react'
import './PedigreeTree.css'
const PedigreeTree = ({ dogId, pedigreeData, coi }) => {
const [translate, setTranslate] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(0.8)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
const container = document.getElementById('tree-container')
if (container) {
setDimensions({
width: container.offsetWidth,
height: container.offsetHeight
})
setTranslate({
x: container.offsetWidth / 4,
y: container.offsetHeight / 2
})
}
}
updateDimensions()
window.addEventListener('resize', updateDimensions)
return () => window.removeEventListener('resize', updateDimensions)
}, [])
const handleZoomIn = () => setZoom(z => Math.min(z + 0.2, 2))
const handleZoomOut = () => setZoom(z => Math.max(z - 0.2, 0.2))
const handleReset = () => {
setZoom(0.8)
setTranslate({
x: dimensions.width / 4,
y: dimensions.height / 2
})
}
const renderCustomNode = ({ nodeDatum, toggleNode }) => {
const isMale = nodeDatum.attributes?.sex === 'male'
const nodeColor = isMale ? '#3b82f6' : '#ec4899'
return (
<g>
<circle
r={30}
fill={nodeColor}
stroke="#fff"
strokeWidth={3}
opacity={0.9}
style={{ cursor: nodeDatum.attributes?.id ? 'pointer' : 'default' }}
onClick={() => {
if (nodeDatum.attributes?.id) {
window.location.href = `/dogs/${nodeDatum.attributes.id}`
}
}}
/>
<text
fill="#fff"
fontSize="24"
textAnchor="middle"
dy="8"
style={{ pointerEvents: 'none' }}
>
{isMale ? '♂' : '♀'}
</text>
<text
fill="#1f2937"
fontSize="14"
fontWeight="600"
textAnchor="middle"
x="0"
y="50"
style={{ pointerEvents: 'none' }}
>
{nodeDatum.name}
</text>
{nodeDatum.attributes?.registration && (
<text
fill="#6b7280"
fontSize="11"
textAnchor="middle"
x="0"
y="65"
style={{ pointerEvents: 'none' }}
>
{nodeDatum.attributes.registration}
</text>
)}
{nodeDatum.attributes?.birth_year && (
<text
fill="#6b7280"
fontSize="11"
textAnchor="middle"
x="0"
y="78"
style={{ pointerEvents: 'none' }}
>
({nodeDatum.attributes.birth_year})
</text>
)}
</g>
)
}
return (
<div className="pedigree-tree-wrapper">
<div className="pedigree-controls">
<div className="control-group">
<button onClick={handleZoomIn} className="control-btn" title="Zoom In">
<ZoomIn size={20} />
</button>
<button onClick={handleZoomOut} className="control-btn" title="Zoom Out">
<ZoomOut size={20} />
</button>
<button onClick={handleReset} className="control-btn" title="Reset View">
<Maximize2 size={20} />
</button>
</div>
{coi !== null && coi !== undefined && (
<div className="coi-display">
<span className="coi-label">COI:</span>
<span className={`coi-value ${coi > 10 ? 'high' : coi > 5 ? 'medium' : 'low'}`}>
{coi.toFixed(2)}%
</span>
</div>
)}
</div>
<div className="pedigree-legend">
<div className="legend-item">
<div className="legend-color male"></div>
<span>Male</span>
</div>
<div className="legend-item">
<div className="legend-color female"></div>
<span>Female</span>
</div>
</div>
<div id="tree-container" className="tree-container">
{pedigreeData && dimensions.width > 0 && (
<Tree
data={pedigreeData}
translate={translate}
zoom={zoom}
onUpdate={({ zoom, translate }) => {
setZoom(zoom)
setTranslate(translate)
}}
orientation="horizontal"
pathFunc="step"
separation={{ siblings: 1.5, nonSiblings: 2 }}
nodeSize={{ x: 200, y: 150 }}
renderCustomNodeElement={renderCustomNode}
enableLegacyTransitions
transitionDuration={300}
/>
)}
</div>
</div>
)
}
export default PedigreeTree