281 lines
11 KiB
JavaScript
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> {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 — <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>
|
|
)
|
|
}
|