GEN-1: add phenotype tokens + Farbschlag catalog (18 varieties + Schecke/Rex modifiers)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
97
gerbil-manager-web/src/genetics/catalog.ts
Normal file
97
gerbil-manager-web/src/genetics/catalog.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Farbschlag catalog: genotype -> German variety name.
|
||||||
|
*
|
||||||
|
* A catalog entry matches when every locus it SPECIFIES equals the genotype's
|
||||||
|
* phenotype token (see phenotype.ts). Unspecified loci are wildcards. The first
|
||||||
|
* matching base-colour entry wins; Schecke/Rex are appended as modifiers.
|
||||||
|
*
|
||||||
|
* Coverage is partial by design — extend BASE_COLORS as more genotype tables
|
||||||
|
* are confirmed. Unknown genotypes fall back to "Unbekannter Farbschlag" plus
|
||||||
|
* the raw genotype string (handled by the caller / index.ts).
|
||||||
|
*
|
||||||
|
* Sourced from the German gerbil-breeder community:
|
||||||
|
* - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik (black/red-eyed tables)
|
||||||
|
* - rennmaus-info.jimdoweb.com (loci & colourpoint series)
|
||||||
|
* - clan-of-topolino.ch, rennmauswelten.jimdofree.com (Zobel, Schimmel)
|
||||||
|
*/
|
||||||
|
import type { LocusKey } from './loci'
|
||||||
|
import type { Genotype } from './genotype'
|
||||||
|
import { phenotypeTokens, type PhenotypeTokens } from './phenotype'
|
||||||
|
|
||||||
|
export interface FarbschlagEntry {
|
||||||
|
/** German variety name. */
|
||||||
|
readonly name: string
|
||||||
|
/** Common English name (reference only; UI is German). */
|
||||||
|
readonly english?: string
|
||||||
|
/** Required phenotype tokens; omitted loci are wildcards. */
|
||||||
|
readonly tokens: Partial<Record<LocusKey, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base colours (the A/C/D/E/G/P phenotype). Ordered most-specific first so the
|
||||||
|
* earliest match wins. Colourpoint (C != "C") entries are naturally disjoint
|
||||||
|
* from full-colour ones because the C token differs.
|
||||||
|
*/
|
||||||
|
export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||||
|
// ── Colourpoint / C-series (black & red eyed) ──
|
||||||
|
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' } },
|
||||||
|
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' } },
|
||||||
|
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' } },
|
||||||
|
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||||
|
|
||||||
|
// ── Schimmel / roan (ef ef) ──
|
||||||
|
{ name: 'Schwarzschimmel', english: 'Black Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'Rotaugenschimmel', english: 'Red-Eyed Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' } },
|
||||||
|
|
||||||
|
// ── Black-eyed full colour (P) ──
|
||||||
|
{ name: 'Agouti', english: 'Golden Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'Schwarz', english: 'Black', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'Silberagouti', english: 'Grey Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||||
|
{ name: 'Anthrazit', english: 'Slate', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||||
|
{ name: 'Algierfuchs', english: 'Dark-Eyed Honey', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'Blau', english: 'Blue', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' } },
|
||||||
|
|
||||||
|
// ── Red-eyed full colour (p) ──
|
||||||
|
{ name: 'Gold', english: 'Argente Golden', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' } },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||||
|
|
||||||
|
export interface FarbschlagMatch {
|
||||||
|
/** German variety name (with Schecke/Rex modifiers appended), or the fallback. */
|
||||||
|
readonly name: string
|
||||||
|
/** The matched base entry, if any. */
|
||||||
|
readonly base: FarbschlagEntry | null
|
||||||
|
/** True when no base colour matched (name is the fallback). */
|
||||||
|
readonly unknown: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean {
|
||||||
|
return (Object.keys(entry.tokens) as LocusKey[]).every(
|
||||||
|
(locus) => tokens[locus] === entry.tokens[locus],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||||
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||||
|
const tokens = phenotypeTokens(g)
|
||||||
|
const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null
|
||||||
|
|
||||||
|
const modifiers: string[] = []
|
||||||
|
if (tokens.Sp === 'Sp') modifiers.push('Schecke')
|
||||||
|
if (tokens.Re === 'Re') modifiers.push('Rex')
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||||
|
}
|
||||||
|
const name = [base.name, ...modifiers].join(' ')
|
||||||
|
return { name, base, unknown: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of base-colour varieties currently catalogued. */
|
||||||
|
export const CATALOG_SIZE = BASE_COLORS.length
|
||||||
35
gerbil-manager-web/src/genetics/phenotype.ts
Normal file
35
gerbil-manager-web/src/genetics/phenotype.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Reduce a genotype to per-locus PHENOTYPE TOKENS — the expressed effect at
|
||||||
|
* each locus, collapsing genotypes that look identical (e.g. AA and Aa both
|
||||||
|
* express agouti -> token "A"). The Farbschlag catalog matches on these tokens.
|
||||||
|
*
|
||||||
|
* Tokens per locus:
|
||||||
|
* A : "A" (agouti, dominant present) | "a" (non-agouti, aa)
|
||||||
|
* C : "C" (full colour) | "cchm" (marked colourpoint) | "ch" (himalayan) [by dominant allele]
|
||||||
|
* D : "D" | "d"
|
||||||
|
* E : "E" (full) | "ef" (Schimmel/roan) | "e" (Fox) [by dominant allele]
|
||||||
|
* G : "G" | "g"
|
||||||
|
* P : "P" (black-eyed) | "p" (red-eyed)
|
||||||
|
* Sp : "Sp" (checkered) | "sp"
|
||||||
|
* Re : "Re" (rex) | "re"
|
||||||
|
*/
|
||||||
|
import { dominantAllele, LOCUS_ORDER, type LocusKey } from './loci'
|
||||||
|
import type { Genotype } from './genotype'
|
||||||
|
import { WILDCARD } from './genotype'
|
||||||
|
|
||||||
|
export type PhenotypeTokens = Record<LocusKey, string>
|
||||||
|
|
||||||
|
/** Expressed (dominant) allele at a locus; throws on unresolved wildcards. */
|
||||||
|
export function expressedAllele(g: Genotype, locus: LocusKey): string {
|
||||||
|
const [a, b] = g[locus]
|
||||||
|
if (a === WILDCARD || b === WILDCARD) {
|
||||||
|
throw new Error(`Cannot resolve phenotype for locus ${locus}: contains wildcard`)
|
||||||
|
}
|
||||||
|
return dominantAllele(locus, a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phenotypeTokens(g: Genotype): PhenotypeTokens {
|
||||||
|
const tokens = {} as PhenotypeTokens
|
||||||
|
for (const locus of LOCUS_ORDER) tokens[locus] = expressedAllele(g, locus)
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user