Add interactive pedigree tree visualization component
This commit is contained in:
234
client/src/components/PedigreeView.jsx
Normal file
234
client/src/components/PedigreeView.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
|
||||
import Tree from 'react-d3-tree'
|
||||
import axios from 'axios'
|
||||
import './PedigreeView.css'
|
||||
|
||||
function PedigreeView({ dogId, onClose }) {
|
||||
const [treeData, setTreeData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [translate, setTranslate] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(0.8)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
fetchPedigree()
|
||||
}, [dogId])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
const container = document.querySelector('.pedigree-container')
|
||||
if (container) {
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
setDimensions({ width, height })
|
||||
setTranslate({ x: width / 4, y: height / 2 })
|
||||
}
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
return () => window.removeEventListener('resize', updateDimensions)
|
||||
}, [])
|
||||
|
||||
const fetchPedigree = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`/api/pedigree/${dogId}?generations=5`)
|
||||
const formatted = formatTreeData(response.data)
|
||||
setTreeData(formatted)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to load pedigree')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTreeData = (dog) => {
|
||||
if (!dog) return null
|
||||
|
||||
const children = []
|
||||
if (dog.sire) children.push(formatTreeData(dog.sire))
|
||||
if (dog.dam) children.push(formatTreeData(dog.dam))
|
||||
|
||||
return {
|
||||
name: dog.name,
|
||||
attributes: {
|
||||
sex: dog.sex,
|
||||
birth_date: dog.birth_date,
|
||||
registration: dog.registration_number,
|
||||
breed: dog.breed,
|
||||
color: dog.color,
|
||||
generation: dog.generation
|
||||
},
|
||||
children: children.length > 0 ? children : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const handleNodeClick = useCallback((nodeData) => {
|
||||
console.log('Node clicked:', nodeData)
|
||||
}, [])
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setZoom(prev => Math.min(prev + 0.2, 2))
|
||||
}
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setZoom(prev => Math.max(prev - 0.2, 0.4))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setZoom(0.8)
|
||||
setTranslate({ x: dimensions.width / 4, y: dimensions.height / 2 })
|
||||
}
|
||||
|
||||
const renderCustomNode = ({ nodeDatum, toggleNode }) => (
|
||||
<g>
|
||||
<circle
|
||||
r="20"
|
||||
fill={nodeDatum.attributes.sex === 'male' ? '#3b82f6' : '#ec4899'}
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
onClick={toggleNode}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<text
|
||||
fill="#fff"
|
||||
strokeWidth="0"
|
||||
x="0"
|
||||
y="5"
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{nodeDatum.attributes.sex === 'male' ? '♂' : '♀'}
|
||||
</text>
|
||||
<text
|
||||
fill="#1f2937"
|
||||
x="30"
|
||||
y="-10"
|
||||
fontSize="14"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{nodeDatum.name}
|
||||
</text>
|
||||
{nodeDatum.attributes.registration && (
|
||||
<text
|
||||
fill="#6b7280"
|
||||
x="30"
|
||||
y="8"
|
||||
fontSize="11"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{nodeDatum.attributes.registration}
|
||||
</text>
|
||||
)}
|
||||
{nodeDatum.attributes.birth_date && (
|
||||
<text
|
||||
fill="#6b7280"
|
||||
x="30"
|
||||
y="22"
|
||||
fontSize="10"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
Born: {new Date(nodeDatum.attributes.birth_date).getFullYear()}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="pedigree-modal">
|
||||
<div className="loading">Loading pedigree...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="pedigree-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Pedigree Tree</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="error">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="pedigree-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Pedigree Tree - {treeData?.name}</h2>
|
||||
<div className="pedigree-controls">
|
||||
<button className="btn-icon" onClick={handleZoomOut} title="Zoom Out">
|
||||
<ZoomOut size={20} />
|
||||
</button>
|
||||
<button className="btn-icon" onClick={handleZoomIn} title="Zoom In">
|
||||
<ZoomIn size={20} />
|
||||
</button>
|
||||
<button className="btn-icon" onClick={handleReset} title="Reset View">
|
||||
<Maximize2 size={20} />
|
||||
</button>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pedigree-legend">
|
||||
<div className="legend-item">
|
||||
<span className="legend-color male"></span>
|
||||
<span>Male</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<span className="legend-color female"></span>
|
||||
<span>Female</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pedigree-container">
|
||||
{treeData && dimensions.width > 0 && (
|
||||
<Tree
|
||||
data={treeData}
|
||||
translate={translate}
|
||||
zoom={zoom}
|
||||
onNodeClick={handleNodeClick}
|
||||
renderCustomNodeElement={renderCustomNode}
|
||||
orientation="horizontal"
|
||||
pathFunc="step"
|
||||
separation={{ siblings: 2, nonSiblings: 2.5 }}
|
||||
nodeSize={{ x: 200, y: 100 }}
|
||||
enableLegacyTransitions
|
||||
transitionDuration={300}
|
||||
collapsible={false}
|
||||
zoomable={true}
|
||||
draggable={true}
|
||||
dimensions={dimensions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pedigree-info">
|
||||
<p>
|
||||
<strong>Tip:</strong> Use mouse wheel to zoom, click and drag to pan.
|
||||
Click on nodes to view details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PedigreeView
|
||||
Reference in New Issue
Block a user