235 lines
6.5 KiB
React
235 lines
6.5 KiB
React
|
|
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
|