Files
breedr/client/src/pages/PairingSimulator.jsx
2026-03-11 23:48:35 -05:00

281 lines
11 KiB
JavaScript

import { useState, useEffect, useCallback } from 'react'
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge, ShieldAlert } from 'lucide-react'
export default function PairingSimulator() {
const [dogs, setDogs] = useState([])
const [sireId, setSireId] = useState('')
const [damId, setDamId] = useState('')
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [dogsLoading, setDogsLoading] = useState(true)
const [relationWarning, setRelationWarning] = useState(null)
const [relationChecking, setRelationChecking] = useState(false)
const [geneticRisk, setGeneticRisk] = useState(null)
const [geneticChecking, setGeneticChecking] = useState(false)
useEffect(() => {
// include_external=1 ensures external sires/dams appear for pairing
fetch('/api/dogs?include_external=1')
.then(r => r.json())
.then(data => {
setDogs(Array.isArray(data) ? data : (data.dogs || []))
setDogsLoading(false)
})
.catch(() => setDogsLoading(false))
}, [])
// Check for direct relation whenever both sire and dam are selected
const checkRelation = useCallback(async (sid, did) => {
if (!sid || !did) {
setRelationWarning(null)
setGeneticRisk(null)
return
}
setRelationChecking(true)
setGeneticChecking(true)
try {
const [relRes, genRes] = await Promise.all([
fetch(`/api/pedigree/relations/${sid}/${did}`),
fetch(`/api/genetics/pairing-risk?sireId=${sid}&damId=${did}`)
])
const relData = await relRes.json()
setRelationWarning(relData.related ? relData.relationship : null)
const genData = await genRes.json()
setGeneticRisk(genData)
} catch {
setRelationWarning(null)
setGeneticRisk(null)
} finally {
setRelationChecking(false)
setGeneticChecking(false)
}
}, [])
function handleSireChange(e) {
const val = e.target.value
setSireId(val)
setResult(null)
checkRelation(val, damId)
}
function handleDamChange(e) {
const val = e.target.value
setDamId(val)
setResult(null)
checkRelation(sireId, val)
}
async function handleSimulate(e) {
e.preventDefault()
if (!sireId || !damId) return
setLoading(true)
setError(null)
setResult(null)
try {
const res = await fetch('/api/pedigree/trial-pairing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Simulation failed')
setResult(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const males = dogs.filter(d => d.sex === 'male')
const females = dogs.filter(d => d.sex === 'female')
const coiColor = (coi) => {
if (coi < 0.0625) return 'var(--success)'
if (coi < 0.125) return 'var(--warning)'
return 'var(--danger)'
}
const coiLabel = (coi) => {
if (coi < 0.0625) return 'Low'
if (coi < 0.125) return 'Moderate'
if (coi < 0.25) return 'High'
return 'Very High'
}
return (
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<FlaskConical size={28} style={{ color: 'var(--primary)' }} />
<h1 style={{ margin: 0 }}>Pairing Simulator</h1>
</div>
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>
Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding.
Includes both kennel and external dogs.
</p>
<div className="card" style={{ marginBottom: '1.5rem' }}>
<form onSubmit={handleSimulate}>
<div className="form-grid" style={{ marginBottom: '1rem' }}>
<div className="form-group">
<label className="label">Sire (Male) *</label>
{dogsLoading ? (
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
) : (
<select className="input" value={sireId} onChange={handleSireChange} required>
<option value="">Select sire...</option>
{males.map(d => (
<option key={d.id} value={d.id}>
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
</option>
))}
</select>
)}
</div>
<div className="form-group">
<label className="label">Dam (Female) *</label>
{dogsLoading ? (
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
) : (
<select className="input" value={damId} onChange={handleDamChange} required>
<option value="">Select dam...</option>
{females.map(d => (
<option key={d.id} value={d.id}>
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
</option>
))}
</select>
)}
</div>
</div>
{relationChecking && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
Checking relationship and genetics...
</div>
)}
{relationWarning && !relationChecking && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1rem', marginBottom: '0.75rem',
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--danger)',
}}>
<ShieldAlert size={16} />
<strong>Related:</strong>&nbsp;{relationWarning}
</div>
)}
{geneticRisk && geneticRisk.risks && geneticRisk.risks.length > 0 && !geneticChecking && (
<div style={{
padding: '0.6rem 1rem', marginBottom: '0.75rem',
background: 'rgba(255,159,10,0.08)', border: '1px solid rgba(255,159,10,0.3)',
borderRadius: 'var(--radius)', fontSize: '0.875rem', color: 'var(--warning)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', fontWeight: 600 }}>
<ShieldAlert size={16} /> Genetic Risks Detected
</div>
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
{geneticRisk.risks.map(r => (
<li key={r.marker}>
<strong>{r.marker}</strong>: {r.message}
</li>
))}
</ul>
</div>
)}
{geneticRisk && geneticRisk.missing_data && !geneticChecking && (
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem', fontStyle: 'italic' }}>
* Sire or dam has missing genetic tests. Clearances cannot be fully verified.
</div>
)}
<button
type="submit"
className="btn btn-primary"
disabled={loading || dogsLoading || !sireId || !damId}
style={{ width: '100%' }}
>
{loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
</button>
</form>
</div>
{error && (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--danger)' }}>
<XCircle size={18} />
<strong>Error:</strong> {error}
</div>
</div>
)}
{result && (
<div className="card">
<h2 style={{ fontSize: '1rem', marginBottom: '1.25rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Simulation Result
</h2>
<div style={{
display: 'flex', alignItems: 'center', gap: '1rem',
padding: '1.25rem', marginBottom: '1rem',
background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
border: `2px solid ${coiColor(result.coi)}`,
}}>
{result.coi < 0.0625
? <CheckCircle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
: <AlertTriangle size={32} style={{ color: coiColor(result.coi), flexShrink: 0 }} />
}
<div>
<div style={{ fontSize: '2rem', fontWeight: 700, color: coiColor(result.coi), lineHeight: 1 }}>
{(result.coi * 100).toFixed(2)}%
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
COI &mdash; <strong style={{ color: coiColor(result.coi) }}>{coiLabel(result.coi)}</strong>
</div>
</div>
</div>
{result.commonAncestors && result.commonAncestors.length > 0 && (
<div>
<h3 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
Common Ancestors ({result.commonAncestors.length})
</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{result.commonAncestors.map((a, i) => (
<span key={i} style={{
padding: '0.2rem 0.6rem',
background: 'var(--bg-tertiary)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8rem',
border: '1px solid var(--border)',
}}>{a.name}</span>
))}
</div>
</div>
)}
{result.recommendation && (
<div style={{
marginTop: '1rem', padding: '0.75rem 1rem',
background: result.coi < 0.0625 ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
borderRadius: 'var(--radius)',
border: `1px solid ${result.coi < 0.0625 ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
fontSize: '0.875rem',
color: 'var(--text-secondary)',
}}>
{result.recommendation}
</div>
)}
</div>
)}
</div>
)
}