feature/external-dogs #50
@@ -13,7 +13,8 @@ export default function PairingSimulator() {
|
|||||||
const [relationChecking, setRelationChecking] = useState(false)
|
const [relationChecking, setRelationChecking] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/dogs')
|
// include_external=1 ensures external sires/dams appear for pairing
|
||||||
|
fetch('/api/dogs?include_external=1')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
setDogs(Array.isArray(data) ? data : (data.dogs || []))
|
||||||
@@ -54,9 +55,6 @@ export default function PairingSimulator() {
|
|||||||
checkRelation(sireId, val)
|
checkRelation(sireId, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
const males = dogs.filter(d => d.sex === 'male')
|
|
||||||
const females = dogs.filter(d => d.sex === 'female')
|
|
||||||
|
|
||||||
async function handleSimulate(e) {
|
async function handleSimulate(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!sireId || !damId) return
|
if (!sireId || !damId) return
|
||||||
@@ -64,16 +62,14 @@ export default function PairingSimulator() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/pedigree/trial-pairing', {
|
const res = await fetch('/api/pedigree/coi', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) })
|
body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
const data = await res.json()
|
||||||
const err = await res.json()
|
if (!res.ok) throw new Error(data.error || 'Simulation failed')
|
||||||
throw new Error(err.error || 'Failed to calculate')
|
setResult(data)
|
||||||
}
|
|
||||||
setResult(await res.json())
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,204 +77,164 @@ export default function PairingSimulator() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function RiskBadge({ coi, recommendation }) {
|
const males = dogs.filter(d => d.sex === 'male')
|
||||||
const isLow = coi < 5
|
const females = dogs.filter(d => d.sex === 'female')
|
||||||
const isMed = coi >= 5 && coi < 10
|
|
||||||
const isHigh = coi >= 10
|
const coiColor = (coi) => {
|
||||||
return (
|
if (coi < 0.0625) return 'var(--success)'
|
||||||
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
if (coi < 0.125) return 'var(--warning)'
|
||||||
{isLow && <CheckCircle size={20} />}
|
return 'var(--danger)'
|
||||||
{isMed && <AlertTriangle size={20} />}
|
}
|
||||||
{isHigh && <XCircle size={20} />}
|
|
||||||
<span>{recommendation}</span>
|
const coiLabel = (coi) => {
|
||||||
</div>
|
if (coi < 0.0625) return 'Low'
|
||||||
)
|
if (coi < 0.125) return 'Moderate'
|
||||||
|
if (coi < 0.25) return 'High'
|
||||||
|
return 'Very High'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem' }}>
|
<div className="container" style={{ paddingTop: '2rem', paddingBottom: '3rem', maxWidth: '720px' }}>
|
||||||
{/* Header */}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<FlaskConical size={28} style={{ color: 'var(--primary)' }} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
<h1 style={{ margin: 0 }}>Pairing Simulator</h1>
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
{/* Selector Card */}
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<div className="card" style={{ marginBottom: '1.5rem', maxWidth: '720px' }}>
|
|
||||||
<form onSubmit={handleSimulate}>
|
<form onSubmit={handleSimulate}>
|
||||||
<div className="form-grid" style={{ marginBottom: '1.25rem' }}>
|
<div className="form-grid" style={{ marginBottom: '1rem' }}>
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group">
|
||||||
<label className="label">Sire (Male) ♂</label>
|
<label className="label">Sire (Male) *</label>
|
||||||
<select
|
{dogsLoading ? (
|
||||||
value={sireId}
|
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
|
||||||
onChange={handleSireChange}
|
) : (
|
||||||
required
|
<select className="input" value={sireId} onChange={handleSireChange} required>
|
||||||
disabled={dogsLoading}
|
<option value="">Select sire...</option>
|
||||||
>
|
{males.map(d => (
|
||||||
<option value="">— Select Sire —</option>
|
<option key={d.id} value={d.id}>
|
||||||
{males.map(d => (
|
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
|
||||||
<option key={d.id} value={d.id}>
|
</option>
|
||||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!dogsLoading && males.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No male dogs registered.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group" style={{ margin: 0 }}>
|
<div className="form-group">
|
||||||
<label className="label">Dam (Female) ♀</label>
|
<label className="label">Dam (Female) *</label>
|
||||||
<select
|
{dogsLoading ? (
|
||||||
value={damId}
|
<div className="input" style={{ color: 'var(--text-muted)' }}>Loading dogs...</div>
|
||||||
onChange={handleDamChange}
|
) : (
|
||||||
required
|
<select className="input" value={damId} onChange={handleDamChange} required>
|
||||||
disabled={dogsLoading}
|
<option value="">Select dam...</option>
|
||||||
>
|
{females.map(d => (
|
||||||
<option value="">— Select Dam —</option>
|
<option key={d.id} value={d.id}>
|
||||||
{females.map(d => (
|
{d.name}{d.is_champion ? ' ✪' : ''}{d.is_external ? ' [Ext]' : ''}
|
||||||
<option key={d.id} value={d.id}>
|
</option>
|
||||||
{d.name}{d.breed ? ` · ${d.breed}` : ''}
|
))}
|
||||||
</option>
|
</select>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!dogsLoading && females.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '0.4rem' }}>No female dogs registered.</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Direct-relation warning banner */}
|
|
||||||
{relationChecking && (
|
{relationChecking && (
|
||||||
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Checking relationship…</p>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
Checking relationship...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!relationChecking && relationWarning && (
|
|
||||||
|
{relationWarning && !relationChecking && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
background: 'rgba(234,179,8,0.12)', border: '1px solid rgba(234,179,8,0.4)',
|
padding: '0.6rem 1rem', marginBottom: '0.75rem',
|
||||||
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1rem'
|
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={18} style={{ color: '#eab308', flexShrink: 0, marginTop: '0.1rem' }} />
|
<ShieldAlert size={16} />
|
||||||
<div>
|
<strong>Related:</strong> {relationWarning}
|
||||||
<p style={{ margin: 0, fontWeight: 600, color: '#eab308', fontSize: '0.875rem' }}>Direct Relation Detected</p>
|
|
||||||
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
|
|
||||||
{relationWarning}. COI will reflect the high inbreeding coefficient for this pairing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!sireId || !damId || loading || relationChecking}
|
disabled={loading || dogsLoading || !sireId || !damId}
|
||||||
style={{ minWidth: '160px' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
{loading ? 'Simulating...' : <><GitMerge size={16} style={{ marginRight: '0.4rem' }} />Simulate Pairing</>}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{error && (
|
||||||
{error && <div className="error" style={{ maxWidth: '720px' }}>{error}</div>}
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{result && (
|
{result && (
|
||||||
<div style={{ maxWidth: '720px' }}>
|
<div className="card">
|
||||||
{/* Direct-relation alert in results */}
|
<h2 style={{ fontSize: '1rem', marginBottom: '1.25rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{result.directRelation && (
|
Simulation Result
|
||||||
<div style={{
|
</h2>
|
||||||
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
|
||||||
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.35)',
|
<div style={{
|
||||||
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1.25rem'
|
display: 'flex', alignItems: 'center', gap: '1rem',
|
||||||
}}>
|
padding: '1.25rem', marginBottom: '1rem',
|
||||||
<ShieldAlert size={18} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: '0.1rem' }} />
|
background: 'var(--bg-primary)', borderRadius: 'var(--radius)',
|
||||||
<div>
|
border: `2px solid ${coiColor(result.coi)}`,
|
||||||
<p style={{ margin: 0, fontWeight: 600, color: 'var(--danger)', fontSize: '0.875rem' }}>Direct Relation — High Inbreeding Risk</p>
|
}}>
|
||||||
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{result.directRelation}</p>
|
{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.common_ancestors && result.common_ancestors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.5rem' }}>
|
||||||
|
Common Ancestors ({result.common_ancestors.length})
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||||
|
{result.common_ancestors.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}</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* COI Summary */}
|
{result.recommendation && (
|
||||||
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
<div style={{
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
marginTop: '1rem', padding: '0.75rem 1rem',
|
||||||
<div>
|
background: result.coi < 0.0625 ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>Pairing</p>
|
borderRadius: 'var(--radius)',
|
||||||
<p style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
|
border: `1px solid ${result.coi < 0.0625 ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||||
<span style={{ color: '#60a5fa' }}>{result.sire.name}</span>
|
fontSize: '0.875rem',
|
||||||
<span style={{ color: 'var(--text-muted)', margin: '0 0.5rem' }}>×</span>
|
color: 'var(--text-secondary)',
|
||||||
<span style={{ color: '#f472b6' }}>{result.dam.name}</span>
|
}}>
|
||||||
</p>
|
{result.recommendation}
|
||||||
</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>
|
||||||
|
)}
|
||||||
<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 6 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user