FEAT-5: Probeverpaarung page — free genotype entry, client-side breed(), Farbschlag cards + warnings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
62
gerbil-manager-web/src/components/ParentSelector.tsx
Normal file
62
gerbil-manager-web/src/components/ParentSelector.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { isValidGenotype, previewFarbschlag } from '../format/genotypeText'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
export interface ParentValue {
|
||||||
|
/** Free-text genotype (GEN-1 compact string; '?' wildcards allowed). */
|
||||||
|
genotype: string
|
||||||
|
/** Name of a picked animal, if the genotype came from one (display only). */
|
||||||
|
animalName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentSelectorProps {
|
||||||
|
label: string
|
||||||
|
value: ParentValue
|
||||||
|
onChange: (value: ParentValue) => void
|
||||||
|
/** Optional animal-picker UI (FEAT-5 increment 3) rendered above the input. */
|
||||||
|
picker?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParentSelector({ label, value, onChange, picker }: ParentSelectorProps) {
|
||||||
|
const t = de.pages.genetik
|
||||||
|
const preview = previewFarbschlag(value.genotype)
|
||||||
|
const invalid = value.genotype.trim() !== '' && !isValidGenotype(value.genotype)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="parent-selector">
|
||||||
|
<legend>{label}</legend>
|
||||||
|
{picker}
|
||||||
|
{value.animalName && (
|
||||||
|
<p className="muted parent-selector__animal">
|
||||||
|
{value.animalName}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => onChange({ genotype: '', animalName: null })}
|
||||||
|
>
|
||||||
|
{t.clearAnimal}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.genotypeLabel}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={value.genotype}
|
||||||
|
placeholder="Aa CC Dd EE GG Pp Spsp rere"
|
||||||
|
onChange={(e) => onChange({ genotype: e.target.value, animalName: null })}
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
{invalid ? (
|
||||||
|
<small className="error-text">{t.genotypeInvalid}</small>
|
||||||
|
) : preview ? (
|
||||||
|
<small className="muted">
|
||||||
|
{t.genotypePreview}: <strong>{preview}</strong>
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small className="muted">{t.genotypeHint}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
gerbil-manager-web/src/format/genotypeText.ts
Normal file
22
gerbil-manager-web/src/format/genotypeText.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** Helpers for free-text genotype input (parse/validate/preview via GEN-1). */
|
||||||
|
import { fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||||
|
|
||||||
|
export function isValidGenotype(genotype: string): boolean {
|
||||||
|
if (!genotype.trim()) return false
|
||||||
|
try {
|
||||||
|
fromDisplayString(genotype)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved Farbschlag for a genotype string, or null if empty/invalid. */
|
||||||
|
export function previewFarbschlag(genotype: string): string | null {
|
||||||
|
if (!genotype.trim()) return null
|
||||||
|
try {
|
||||||
|
return genotypeToFarbschlag(fromDisplayString(genotype))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -457,3 +457,106 @@ textarea {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Genetik / Probeverpaarung (FEAT-5) ──────────────────────── */
|
||||||
|
|
||||||
|
.parents-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.parents-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-selector {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-selector legend {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-selector__animal {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert--warning {
|
||||||
|
background: #fdf2dc;
|
||||||
|
border: 1px solid #e6c878;
|
||||||
|
color: #7a5a14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbschlag-cards {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbschlag-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbschlag-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbschlag-card__prob {
|
||||||
|
color: var(--color-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genotype-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genotype-table th,
|
||||||
|
.genotype-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genotype-table code {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,111 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
import { breed, fromDisplayString, type BreedingResult } from '../genetics'
|
||||||
|
import ParentSelector, { type ParentValue } from '../components/ParentSelector'
|
||||||
|
import { isValidGenotype } from '../format/genotypeText'
|
||||||
|
|
||||||
|
const EMPTY_PARENT: ParentValue = { genotype: '', animalName: null }
|
||||||
|
|
||||||
|
function warningText(code: string): string {
|
||||||
|
const map = de.genetics.warnings as Record<string, string>
|
||||||
|
return map[code] ?? code
|
||||||
|
}
|
||||||
|
|
||||||
export default function GenetikPage() {
|
export default function GenetikPage() {
|
||||||
const t = de.pages.genetik
|
const t = de.pages.genetik
|
||||||
|
const [father, setFather] = useState<ParentValue>(EMPTY_PARENT)
|
||||||
|
const [mother, setMother] = useState<ParentValue>(EMPTY_PARENT)
|
||||||
|
const [showGenotypes, setShowGenotypes] = useState(false)
|
||||||
|
|
||||||
|
const bothValid = isValidGenotype(father.genotype) && isValidGenotype(mother.genotype)
|
||||||
|
|
||||||
|
const result: BreedingResult | null = useMemo(() => {
|
||||||
|
if (!bothValid) return null
|
||||||
|
// breed() is pure & client-side; ParentSelector already validated both, so
|
||||||
|
// fromDisplayString won't throw.
|
||||||
|
return breed(fromDisplayString(father.genotype), fromDisplayString(mother.genotype))
|
||||||
|
}, [bothValid, father.genotype, mother.genotype])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
<p className="muted">{t.subtitle}</p>
|
<p className="muted">{t.subtitle}</p>
|
||||||
|
|
||||||
|
<div className="parents-grid">
|
||||||
|
<ParentSelector label={t.father} value={father} onChange={setFather} />
|
||||||
|
<ParentSelector label={t.mother} value={mother} onChange={setMother} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!bothValid && <p className="muted">{t.needBoth}</p>}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="results">
|
||||||
|
{result.warnings.length > 0 && (
|
||||||
|
<div className="warnings">
|
||||||
|
<h3>{t.warningsTitle}</h3>
|
||||||
|
{result.warnings.map((w) => (
|
||||||
|
<div key={w.code} className="alert alert--warning">
|
||||||
|
<span>{warningText(w.code)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>{t.resultsTitle}</h3>
|
||||||
|
{result.byFarbschlag.length === 0 ? (
|
||||||
|
<p className="muted">{t.noOffspring}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h4 className="muted">{t.byFarbschlag}</h4>
|
||||||
|
<ul className="farbschlag-cards">
|
||||||
|
{result.byFarbschlag.map((f) => (
|
||||||
|
<li key={f.farbschlag} className="farbschlag-card">
|
||||||
|
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
||||||
|
<span className="farbschlag-card__prob">
|
||||||
|
{f.probability.percent}
|
||||||
|
<small> ({f.probability.text})</small>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => setShowGenotypes((s) => !s)}
|
||||||
|
aria-expanded={showGenotypes}
|
||||||
|
>
|
||||||
|
{showGenotypes ? t.hideGenotypes : t.showGenotypes}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showGenotypes && (
|
||||||
|
<table className="genotype-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t.genotypeLabel}</th>
|
||||||
|
<th>{t.genotypePreview}</th>
|
||||||
|
<th>{t.probability}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.offspring.map((o) => (
|
||||||
|
<tr key={o.genotype}>
|
||||||
|
<td>
|
||||||
|
<code>{o.genotype}</code>
|
||||||
|
</td>
|
||||||
|
<td>{o.farbschlag}</td>
|
||||||
|
<td>
|
||||||
|
{o.probability.percent} <small>({o.probability.text})</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user