fix(frontend): block/warn direct parent-offspring selections in PairingSimulator
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge } from 'lucide-react'
|
import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge, ShieldAlert } from 'lucide-react'
|
||||||
|
|
||||||
export default function PairingSimulator() {
|
export default function PairingSimulator() {
|
||||||
const [dogs, setDogs] = useState([])
|
const [dogs, setDogs] = useState([])
|
||||||
@@ -9,6 +9,8 @@ export default function PairingSimulator() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [dogsLoading, setDogsLoading] = useState(true)
|
const [dogsLoading, setDogsLoading] = useState(true)
|
||||||
|
const [relationWarning, setRelationWarning] = useState(null)
|
||||||
|
const [relationChecking, setRelationChecking] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/dogs')
|
fetch('/api/dogs')
|
||||||
@@ -20,7 +22,39 @@ export default function PairingSimulator() {
|
|||||||
.catch(() => setDogsLoading(false))
|
.catch(() => setDogsLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const males = dogs.filter(d => d.sex === 'male')
|
// Check for direct relation whenever both sire and dam are selected
|
||||||
|
const checkRelation = useCallback(async (sid, did) => {
|
||||||
|
if (!sid || !did) {
|
||||||
|
setRelationWarning(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRelationChecking(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/pedigree/relations/${sid}/${did}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setRelationWarning(data.related ? data.relationship : null)
|
||||||
|
} catch {
|
||||||
|
setRelationWarning(null)
|
||||||
|
} finally {
|
||||||
|
setRelationChecking(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const males = dogs.filter(d => d.sex === 'male')
|
||||||
const females = dogs.filter(d => d.sex === 'female')
|
const females = dogs.filter(d => d.sex === 'female')
|
||||||
|
|
||||||
async function handleSimulate(e) {
|
async function handleSimulate(e) {
|
||||||
@@ -48,13 +82,13 @@ export default function PairingSimulator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RiskBadge({ coi, recommendation }) {
|
function RiskBadge({ coi, recommendation }) {
|
||||||
const isLow = coi < 5
|
const isLow = coi < 5
|
||||||
const isMed = coi >= 5 && coi < 10
|
const isMed = coi >= 5 && coi < 10
|
||||||
const isHigh = coi >= 10
|
const isHigh = coi >= 10
|
||||||
return (
|
return (
|
||||||
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
|
||||||
{isLow && <CheckCircle size={20} />}
|
{isLow && <CheckCircle size={20} />}
|
||||||
{isMed && <AlertTriangle size={20} />}
|
{isMed && <AlertTriangle size={20} />}
|
||||||
{isHigh && <XCircle size={20} />}
|
{isHigh && <XCircle size={20} />}
|
||||||
<span>{recommendation}</span>
|
<span>{recommendation}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +118,7 @@ export default function PairingSimulator() {
|
|||||||
<label className="label">Sire (Male) ♂</label>
|
<label className="label">Sire (Male) ♂</label>
|
||||||
<select
|
<select
|
||||||
value={sireId}
|
value={sireId}
|
||||||
onChange={e => setSireId(e.target.value)}
|
onChange={handleSireChange}
|
||||||
required
|
required
|
||||||
disabled={dogsLoading}
|
disabled={dogsLoading}
|
||||||
>
|
>
|
||||||
@@ -104,7 +138,7 @@ export default function PairingSimulator() {
|
|||||||
<label className="label">Dam (Female) ♀</label>
|
<label className="label">Dam (Female) ♀</label>
|
||||||
<select
|
<select
|
||||||
value={damId}
|
value={damId}
|
||||||
onChange={e => setDamId(e.target.value)}
|
onChange={handleDamChange}
|
||||||
required
|
required
|
||||||
disabled={dogsLoading}
|
disabled={dogsLoading}
|
||||||
>
|
>
|
||||||
@@ -121,10 +155,30 @@ export default function PairingSimulator() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Direct-relation warning banner */}
|
||||||
|
{relationChecking && (
|
||||||
|
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Checking relationship…</p>
|
||||||
|
)}
|
||||||
|
{!relationChecking && relationWarning && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
||||||
|
background: 'rgba(234,179,8,0.12)', border: '1px solid rgba(234,179,8,0.4)',
|
||||||
|
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
<ShieldAlert size={18} style={{ color: '#eab308', flexShrink: 0, marginTop: '0.1rem' }} />
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!sireId || !damId || loading}
|
disabled={!sireId || !damId || loading || relationChecking}
|
||||||
style={{ minWidth: '160px' }}
|
style={{ minWidth: '160px' }}
|
||||||
>
|
>
|
||||||
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
|
||||||
@@ -138,6 +192,21 @@ export default function PairingSimulator() {
|
|||||||
{/* Results */}
|
{/* Results */}
|
||||||
{result && (
|
{result && (
|
||||||
<div style={{ maxWidth: '720px' }}>
|
<div style={{ maxWidth: '720px' }}>
|
||||||
|
{/* Direct-relation alert in results */}
|
||||||
|
{result.directRelation && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
|
||||||
|
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.35)',
|
||||||
|
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1.25rem'
|
||||||
|
}}>
|
||||||
|
<ShieldAlert size={18} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: '0.1rem' }} />
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* COI Summary */}
|
{/* COI Summary */}
|
||||||
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
<div className="card" style={{ marginBottom: '1.25rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
|
||||||
@@ -152,9 +221,7 @@ export default function PairingSimulator() {
|
|||||||
<div style={{ textAlign: 'right' }}>
|
<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={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: '2rem',
|
fontSize: '2rem', fontWeight: 700, lineHeight: 1,
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
|
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
|
||||||
}}>
|
}}>
|
||||||
{result.coi.toFixed(2)}%
|
{result.coi.toFixed(2)}%
|
||||||
@@ -183,7 +250,7 @@ export default function PairingSimulator() {
|
|||||||
|
|
||||||
{result.commonAncestors.length === 0 ? (
|
{result.commonAncestors.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 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.
|
No common ancestors found within 6 generations. This pairing has excellent genetic diversity.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user