Merge pull request 'feat: Trial Pairing Simulator - COI calculator with common ancestors' (#22) from feat/trial-pairing-simulator into master
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build & Publish Docker Image / build-and-push (push) Has been cancelled
Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'
|
||||
import { Home, Users, Activity, Heart } from 'lucide-react'
|
||||
import { BrowserRouter as Router, Routes, Route, Link} from 'react-router-dom'
|
||||
import { Home, Users, Activity, Heart, FlaskConical } from 'lucide-react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import DogList from './pages/DogList'
|
||||
import DogDetail from './pages/DogDetail'
|
||||
import PedigreeView from './pages/PedigreeView'
|
||||
import LitterList from './pages/LitterList'
|
||||
import BreedingCalendar from './pages/BreedingCalendar'
|
||||
import PairingSimulator from './pages/PairingSimulator'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
@@ -39,6 +40,10 @@ function App() {
|
||||
<Heart size={20} />
|
||||
<span>Breeding</span>
|
||||
</Link>
|
||||
<Link to="/pairing" className="nav-link">
|
||||
<FlaskConical size={20} />
|
||||
<span>Pairing</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -51,6 +56,7 @@ function App() {
|
||||
<Route path="/pedigree/:id" element={<PedigreeView />} />
|
||||
<Route path="/litters" element={<LitterList />} />
|
||||
<Route path="/breeding" element={<BreedingCalendar />} />
|
||||
<Route path="/pairing" element={<PairingSimulator />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -462,3 +462,32 @@ select {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Risk Badge - Pairing Simulator */
|
||||
.risk-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.risk-low {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.risk-med {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.risk-high {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
219
client/src/pages/PairingSimulator.jsx
Normal file
219
client/src/pages/PairingSimulator.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge } 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)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/dogs')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
||||
setDogsLoading(false)
|
||||
})
|
||||
.catch(() => setDogsLoading(false))
|
||||
}, [])
|
||||
|
||||
const males = dogs.filter(d => d.sex === 'male')
|
||||
const females = dogs.filter(d => d.sex === 'female')
|
||||
|
||||
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) })
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Failed to calculate')
|
||||
}
|
||||
setResult(await res.json())
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function RiskBadge({ coi, recommendation }) {
|
||||
const isLow = coi < 5
|
||||
const isMed = coi >= 5 && coi < 10
|
||||
const isHigh = coi >= 10
|
||||
return (
|
||||
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
||||
{isLow && <CheckCircle size={20} />}
|
||||
{isMed && <AlertTriangle size={20} />}
|
||||
{isHigh && <XCircle size={20} />}
|
||||
<span>{recommendation}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: 'var(--radius)', background: 'rgba(139,92,246,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)' }}>
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
<h1 style={{ fontSize: '1.75rem', margin: 0 }}>Trial Pairing Simulator</h1>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0 }}>
|
||||
Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selector Card */}
|
||||
<div className="card" style={{ marginBottom: '1.5rem', maxWidth: '720px' }}>
|
||||
<form onSubmit={handleSimulate}>
|
||||
<div className="form-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label className="label">Sire (Male) ♂</label>
|
||||
<select
|
||||
value={sireId}
|
||||
onChange={e => setSireId(e.target.value)}
|
||||
required
|
||||
disabled={dogsLoading}
|
||||
>
|
||||
<option value="">— Select Sire —</option>
|
||||
{males.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!dogsLoading && males.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No male dogs registered.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ margin: 0 }}>
|
||||
<label className="label">Dam (Female) ♀</label>
|
||||
<select
|
||||
value={damId}
|
||||
onChange={e => setDamId(e.target.value)}
|
||||
required
|
||||
disabled={dogsLoading}
|
||||
>
|
||||
<option value="">— Select Dam —</option>
|
||||
{females.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!dogsLoading && females.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!sireId || !damId || loading}
|
||||
style={{ minWidth: '160px' }}
|
||||
>
|
||||
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <div className="error" style={{ maxWidth: '720px' }}>{error}</div>}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div style={{ maxWidth: '720px' }}>
|
||||
{/* COI Summary */}
|
||||
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>Pairing</p>
|
||||
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
|
||||
<span style={{ color: '#60a5fa' }}>{result.sire.name}</span>
|
||||
<span style={{ color: 'var(--text-muted)', margin: '0 0.5rem' }}>×</span>
|
||||
<span style={{ color: '#f472b6' }}>{result.dam.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
|
||||
<p style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
|
||||
}}>
|
||||
{result.coi.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<RiskBadge coi={result.coi} recommendation={result.recommendation} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
||||
<strong>COI Guide:</strong> <5% Low risk · 5–10% Moderate risk · >10% High risk
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Common Ancestors */}
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<GitMerge size={18} style={{ color: 'var(--accent)' }} />
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Common Ancestors</h3>
|
||||
<span className="badge badge-primary" style={{ marginLeft: 'auto' }}>
|
||||
{result.commonAncestors.length} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.commonAncestors.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 0 }}>
|
||||
No common ancestors found within 5 generations. This pairing has excellent genetic diversity.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Ancestor</th>
|
||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Sire Gen</th>
|
||||
<th style={{ textAlign: 'center', padding: '0.625rem 0.75rem', color: 'var(--text-muted)', fontWeight: 500, fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Dam Gen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.commonAncestors.map((anc, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '0.625rem 0.75rem', fontWeight: 500 }}>{anc.name}</td>
|
||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
||||
<span className="badge badge-primary">Gen {anc.sireGen}</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.625rem 0.75rem', textAlign: 'center' }}>
|
||||
<span className="badge" style={{ background: 'rgba(244,114,182,0.15)', color: '#f472b6' }}>Gen {anc.damGen}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user