reverse pedigree

This commit is contained in:
2026-03-12 07:27:41 -05:00
parent 5ca594fdc7
commit 42bab14ac3
2 changed files with 143 additions and 67 deletions

View File

@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, GitBranch, AlertCircle, Loader } from 'lucide-react' import { ArrowLeft, GitBranch, AlertCircle, Loader } from 'lucide-react'
import axios from 'axios' import axios from 'axios'
import PedigreeTree from '../components/PedigreeTree' import PedigreeTree from '../components/PedigreeTree'
import { transformPedigreeData, formatCOI, getPedigreeCompleteness } from '../utils/pedigreeHelpers' import { transformPedigreeData, transformDescendantData, formatCOI, getPedigreeCompleteness } from '../utils/pedigreeHelpers'
function PedigreeView() { function PedigreeView() {
const { id } = useParams() const { id } = useParams()
@@ -14,28 +14,39 @@ function PedigreeView() {
const [pedigreeData, setPedigreeData] = useState(null) const [pedigreeData, setPedigreeData] = useState(null)
const [coiData, setCoiData] = useState(null) const [coiData, setCoiData] = useState(null)
const [generations, setGenerations] = useState(5) const [generations, setGenerations] = useState(5)
const [viewMode, setViewMode] = useState('ancestors')
useEffect(() => { useEffect(() => {
fetchPedigreeData() fetchPedigreeData()
}, [id, generations]) }, [id, generations, viewMode])
const fetchPedigreeData = async () => { const fetchPedigreeData = async () => {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const pedigreeRes = await axios.get(`/api/pedigree/${id}`) if (viewMode === 'ancestors') {
const dogData = pedigreeRes.data const pedigreeRes = await axios.get(`/api/pedigree/${id}`)
setDog(dogData) const dogData = pedigreeRes.data
setDog(dogData)
const treeData = transformPedigreeData(dogData, generations) const treeData = transformPedigreeData(dogData, generations)
setPedigreeData(treeData) setPedigreeData(treeData)
try { try {
const coiRes = await axios.get(`/api/pedigree/${id}/coi`) const coiRes = await axios.get(`/api/pedigree/${id}/coi`)
setCoiData(coiRes.data) setCoiData(coiRes.data)
} catch (coiError) { } catch (coiError) {
console.warn('COI calculation unavailable:', coiError) console.warn('COI calculation unavailable:', coiError)
setCoiData(null)
}
} else {
const descendantRes = await axios.get(`/api/pedigree/${id}/descendants?generations=${generations}`)
const dogData = descendantRes.data
setDog(dogData)
const treeData = transformDescendantData(dogData, generations)
setPedigreeData(treeData)
setCoiData(null) setCoiData(null)
} }
@@ -86,7 +97,7 @@ function PedigreeView() {
return ( return (
<div className="container"> <div className="container">
{/* Header */} {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => navigate(`/dogs/${id}`)} onClick={() => navigate(`/dogs/${id}`)}
@@ -96,10 +107,10 @@ function PedigreeView() {
Back to Profile Back to Profile
</button> </button>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: '200px' }}>
<h1 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <h1 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<GitBranch size={32} style={{ color: 'var(--primary)' }} /> <GitBranch size={32} style={{ color: 'var(--primary)' }} />
{dog?.name}'s Pedigree {dog?.name}'s {viewMode === 'ancestors' ? 'Pedigree' : 'Descendants'}
</h1> </h1>
{dog?.registration_number && ( {dog?.registration_number && (
<p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0 0' }}> <p style={{ color: 'var(--text-secondary)', margin: '0.25rem 0 0 0' }}>
@@ -107,65 +118,86 @@ function PedigreeView() {
</p> </p>
)} )}
</div> </div>
<div style={{ display: 'flex', background: 'var(--bg-tertiary)', padding: '4px', borderRadius: 'var(--radius)' }}>
<button
className={`btn ${viewMode === 'ancestors' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setViewMode('ancestors')}
style={{ padding: '0.5rem 1rem' }}
>
Ancestors
</button>
<button
className={`btn ${viewMode === 'descendants' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setViewMode('descendants')}
style={{ padding: '0.5rem 1rem' }}
>
Descendants
</button>
</div>
</div> </div>
{/* Stats Bar */} {/* Stats Bar */}
<div className="card" style={{ marginBottom: '1rem', padding: '1rem' }}> <div className="card" style={{ marginBottom: '1rem', padding: '1rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
{/* COI */} {viewMode === 'ancestors' && (
<div> <>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}> {/* COI */}
Coefficient of Inbreeding <div>
</div> <div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> Coefficient of Inbreeding
<span style={{ fontSize: '1.5rem', fontWeight: '700', color: coiInfo.color }}> </div>
{coiInfo.value} <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
</span> <span style={{ fontSize: '1.5rem', fontWeight: '700', color: coiInfo.color }}>
<span style={{ {coiInfo.value}
fontSize: '0.75rem', </span>
padding: '0.25rem 0.5rem', <span style={{
borderRadius: '4px', fontSize: '0.75rem',
background: coiInfo.color + '20', padding: '0.25rem 0.5rem',
color: coiInfo.color, borderRadius: '4px',
textTransform: 'uppercase', background: coiInfo.color + '20',
fontWeight: '600' color: coiInfo.color,
}}> textTransform: 'uppercase',
{coiInfo.level} fontWeight: '600'
</span> }}>
</div> {coiInfo.level}
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}> </span>
{coiInfo.description} </div>
</div> <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
</div> {coiInfo.description}
</div>
{/* Completeness */}
<div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
Pedigree Completeness
</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', color: 'var(--text-primary)' }}>
{completeness}%
</div>
<div style={{ marginTop: '0.5rem' }}>
<div style={{
height: '6px',
background: 'var(--bg-tertiary)',
borderRadius: '3px',
overflow: 'hidden',
border: '1px solid var(--border)'
}}>
<div style={{
height: '100%',
width: `${completeness}%`,
background: barColor,
borderRadius: '3px',
transition: 'width 0.4s ease',
boxShadow: `0 0 6px ${barColor}`
}} />
</div> </div>
</div>
</div> {/* Completeness */}
<div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
Pedigree Completeness
</div>
<div style={{ fontSize: '1.5rem', fontWeight: '700', color: 'var(--text-primary)' }}>
{completeness}%
</div>
<div style={{ marginTop: '0.5rem' }}>
<div style={{
height: '6px',
background: 'var(--bg-tertiary)',
borderRadius: '3px',
overflow: 'hidden',
border: '1px solid var(--border)'
}}>
<div style={{
height: '100%',
width: `${completeness}%`,
background: barColor,
borderRadius: '3px',
transition: 'width 0.4s ease',
boxShadow: `0 0 6px ${barColor}`
}} />
</div>
</div>
</div>
</>
)}
{/* Generations */} {/* Generations */}
<div> <div>

View File

@@ -181,3 +181,47 @@ export const getPedigreeCompleteness = (treeData, targetGenerations = 5) => {
const actualCount = countAncestors(treeData) const actualCount = countAncestors(treeData)
return Math.min(100, Math.round((actualCount / expectedTotal) * 100)) return Math.min(100, Math.round((actualCount / expectedTotal) * 100))
} }
/**
* Transform API descendant data to react-d3-tree format
* @param {Object} dog - Dog object from API with nested offspring array
* @param {number} maxGenerations - Maximum generations to display (default 3)
* @returns {Object} Tree data in react-d3-tree format
*/
export const transformDescendantData = (dog, maxGenerations = 3) => {
if (!dog) return null
const buildTree = (dogData, generation = 0) => {
if (!dogData || generation >= maxGenerations) {
return null
}
const node = {
name: dogData.name || 'Unknown',
attributes: {
id: dogData.id,
sex: dogData.sex,
registration: dogData.registration_number || '',
birth_year: dogData.birth_date ? new Date(dogData.birth_date).getFullYear() : ''
},
children: []
}
if (dogData.offspring && dogData.offspring.length > 0) {
dogData.offspring.forEach(child => {
const childNode = buildTree(child, generation + 1)
if (childNode) {
node.children.push(childNode)
}
})
}
if (node.children.length === 0) {
delete node.children
}
return node
}
return buildTree(dog)
}