From d99294e30eaba4a6c828ab377dc58065dec0504f Mon Sep 17 00:00:00 2001 From: Gulum Date: Fri, 5 Jun 2026 23:41:34 +0200 Subject: [PATCH] GEN-1: add phenotype tokens + Farbschlag catalog (18 varieties + Schecke/Rex modifiers) Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/src/genetics/catalog.ts | 97 ++++++++++++++++++++ gerbil-manager-web/src/genetics/phenotype.ts | 35 +++++++ 2 files changed, 132 insertions(+) create mode 100644 gerbil-manager-web/src/genetics/catalog.ts create mode 100644 gerbil-manager-web/src/genetics/phenotype.ts diff --git a/gerbil-manager-web/src/genetics/catalog.ts b/gerbil-manager-web/src/genetics/catalog.ts new file mode 100644 index 0000000..decdd06 --- /dev/null +++ b/gerbil-manager-web/src/genetics/catalog.ts @@ -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> +} + +/** + * 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 diff --git a/gerbil-manager-web/src/genetics/phenotype.ts b/gerbil-manager-web/src/genetics/phenotype.ts new file mode 100644 index 0000000..833818a --- /dev/null +++ b/gerbil-manager-web/src/genetics/phenotype.ts @@ -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 + +/** 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 +}