Beim Prod-Nachtest aufgefallen: Bei Schecken meldete die Akte eine Abweichung, obwohl eingetragener und errechneter Farbschlag identisch angezeigt wurden (JackJack: „Saphir Schecke" vs. „Saphir Schecke"). Zwei Ursachen: - Der Vergleich schnitt „Schecke"/„Rex" nur auf der ERRECHNETEN Seite ab, nicht auf der eingetragenen (die den Zusatz über formatVarietyName trägt). Jetzt werden beide Seiten gleich normalisiert. - formatVarietyName hängte den Modifier auch an, wenn der Katalogname ihn schon trägt („Schwarz Schecke" → „Schwarz Schecke Schecke"). Tests: 3 vitest-Fälle zu formatVarietyName, e2e SCHECKE-MISMATCH mit Gegenprobe (Mock-Tier mit echter Abweichung zeigt den Hinweis weiter). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
448 lines
26 KiB
TypeScript
448 lines
26 KiB
TypeScript
/**
|
|
* Farbschlag catalog: genotype -> German variety name + the full named catalog.
|
|
*
|
|
* 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 entry wins; Schecke/Rex are appended as modifiers.
|
|
*
|
|
* Two roles, one list:
|
|
* - REVERSE MATCH (genotypeToFarbschlag): first match wins, so the frozen/base
|
|
* names are listed FIRST and win their token-set. Genotype-identical synonyms
|
|
* (e.g. "-hell" intensity variants) are listed after and simply won't be the
|
|
* reverse-match result — that's expected (shade isn't encoded in the 6 loci).
|
|
* - FULL NAMED LIST (CATALOG seed + dropdowns + images): every entry is a
|
|
* distinct user-pickable variety with its own image.
|
|
*
|
|
* Sources:
|
|
* - First 18: de.wikibooks Rennmaus-Farbgenetik, rennmaus-info, clan-of-topolino,
|
|
* rennmauswelten. These names are a FROZEN contract (DB keys) — never rename.
|
|
* - Remaining 55 (GEN-2): baseportal.de "Farben" DB (78 rows; 6 "zur Scheckung"
|
|
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
|
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
|
*/
|
|
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
|
import {
|
|
makeGenotype,
|
|
resolveAllelePair,
|
|
toDisplayString,
|
|
fromDisplayString,
|
|
wildType,
|
|
type AllelePair,
|
|
type Genotype,
|
|
} from './genotype'
|
|
|
|
export interface FarbschlagEntry {
|
|
/** German variety name. FROZEN for the original 18 (DB keys). */
|
|
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>>
|
|
/** Image filename under /farbschlaege/ (public), if available. */
|
|
readonly image?: string
|
|
}
|
|
|
|
/**
|
|
* Ordered so the earliest match wins. The frozen 18 come first (they win their
|
|
* token-sets); the baseportal varieties follow, base before "-hell" within each
|
|
* portal collision group.
|
|
*/
|
|
export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|
// ── Frozen names (DB-key contract; do not rename) ──
|
|
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
|
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
|
// to the frozen-name rule; the ColorVariety seed drops it too.
|
|
{ name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
|
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
|
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
|
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
|
{ name: 'Rotaugenschimmel', english: 'Red-Eyed Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotaugen-schimmel.jpg' },
|
|
{ name: 'Agouti', english: 'Golden Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-mit-erklaerung-der-genloci.JPG' },
|
|
{ name: 'Schwarz', english: 'Black', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'schwarz.jpg' },
|
|
{ name: 'Silberagouti', english: 'Grey Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti.jpg' },
|
|
{ name: 'Anthrazit', english: 'Slate', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit.jpg' },
|
|
{ name: 'Algierfuchs', english: 'Dark-Eyed Honey', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs.jpg' },
|
|
{ name: 'Blau', english: 'Blue', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'blau-schwarz-dd.JPG' },
|
|
{ name: 'Gold', english: 'Argente Golden', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold.jpg' },
|
|
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C/C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
|
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
|
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
|
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
|
{ name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
|
|
|
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
|
|
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
|
|
{ name: 'Apricot (Blassfuchs)', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'apricot-blassfuchs.jpg' },
|
|
{ name: 'Blaufuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs.jpg' },
|
|
{ name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' },
|
|
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
|
|
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
|
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
|
{ name: 'Saphir', tokens: { A: 'a', C: 'C/cchm', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
|
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
|
|
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
|
|
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
|
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
|
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C/ch', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
|
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
|
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
|
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
|
{ name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
|
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
|
|
// agouti+dilute+fox combinations not otherwise covered in the catalog.
|
|
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
|
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
|
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
|
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
|
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
|
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
|
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
|
|
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
|
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
|
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
|
{ name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' },
|
|
{ name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' },
|
|
{ name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' },
|
|
{ name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' },
|
|
{ name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' },
|
|
{ name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' },
|
|
{ name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' },
|
|
{ name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' },
|
|
{ name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' },
|
|
{ name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' },
|
|
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
|
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
|
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
|
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
|
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed (P, line 75); dilution dd is
|
|
// independent of the eye-pigment P-locus, so the dilute form is ALSO P:'P'
|
|
// (was P:'p', which made it an unreachable phantom and left dd CP-fox animals
|
|
// 'Unbekannt'/'blau'). Now aa cchm dd ee gg P- → 'Dilute CP-Blaufuchs'.
|
|
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-dd.jpg' },
|
|
|
|
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
|
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
|
|
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
|
|
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
|
|
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
|
|
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
|
|
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
|
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
|
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
|
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
|
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
|
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
|
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
|
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
|
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
|
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
|
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
|
|
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
|
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
|
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
|
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
|
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', 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
|
|
}
|
|
|
|
/**
|
|
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
|
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
|
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
|
* a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
|
|
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
|
|
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
|
|
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
|
|
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
|
|
* variety specifically.
|
|
*/
|
|
function locusToken(g: Genotype, locus: LocusKey): string {
|
|
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
|
|
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
|
|
// back to the wild-type reading (most-dominant colour / unmarked marker).
|
|
const [x, y] = resolveAllelePair(locus, g[locus])
|
|
if (locus === 'E') {
|
|
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
|
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
|
|
// enables catalog entries like Kohlfuchsschimmel to match het animals).
|
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
|
|
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
|
}
|
|
return dominantAllele(locus, x, y)
|
|
}
|
|
|
|
/**
|
|
* GEN-6 (Tickets bde4ec70 / f89e95ad): ein Katalog-Token darf ein EXAKTES,
|
|
* ungeordnetes Allelpaar "x/y" verlangen statt des exprimierten (dominanten)
|
|
* Allels. Nötig, weil sich Platin / Saphir / Platin-Hell NUR in der C-Zygotie
|
|
* unterscheiden — die Züchterin autoritativ: "Saphir ist aa Cc[chm] D- E- G- pp
|
|
* und Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!":
|
|
* aa CC D- E- G- pp -> Platin
|
|
* aa C/c[chm] D- E- G- pp -> Saphir
|
|
* aa C/c[h] D- E- G- pp -> Platin-Hell (GEN-3g: "-Hell" == c[h]-Allel)
|
|
* Vorher trugen die drei Einträge identische Tokens (C:'C'), weil locusToken()
|
|
* den C-Locus auf das dominante Allel reduziert → "first match wins" lieferte
|
|
* immer Platin, Saphir/Platin-Hell waren unerreichbar. Die "x/y"-Konvention
|
|
* existierte schon für die het-Colourpoints (Siam/Zobel-Hell, C:'cchm/ch') und
|
|
* wird von representativeGenotype() bereits verstanden (GEN-3f) — deshalb bleibt
|
|
* die Katalog-REIHENFOLGE (und damit sortOrder + ColorVariety-GUIDs) unberührt.
|
|
*/
|
|
function pairMatches(g: Genotype, locus: LocusKey, token: string): boolean {
|
|
const [a, b] = resolvedPair(g, locus)
|
|
const [x, y] = token.split('/') as [string, string]
|
|
return (a === x && b === y) || (a === y && b === x)
|
|
}
|
|
|
|
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
|
return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => {
|
|
const token = entry.tokens[locus]!
|
|
return token.includes('/') ? pairMatches(g, locus, token) : locusToken(g, locus) === token
|
|
})
|
|
}
|
|
|
|
/**
|
|
* E-locus family: used to scope the catalog search to E-aware entries.
|
|
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
|
|
* implies a non-dominant extension pair, or null for full-extension/unknown.
|
|
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
|
|
* They are ONLY used here as catalog-search filters; they must NEVER appear as
|
|
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
|
*/
|
|
function eFamily(g: Genotype): string | null {
|
|
// GEN-5: resolve unknown E as a copy of the known allele first ([e,?]→ee Fuchs,
|
|
// [ef,?]→ef/ef Schimmel, [E,?]→EE full), so families are decided consistently.
|
|
const [x, y] = resolveAllelePair('E', g.E)
|
|
if (x === 'e' && y === 'e') return 'Fuchs'
|
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
|
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
|
return null
|
|
}
|
|
|
|
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
|
/**
|
|
* Resolve an allele pair to concrete alleles. GEN-5: an unknown allele copies the
|
|
* known partner (resolveAllelePair); a fully-unknown locus falls back to wild-type.
|
|
*/
|
|
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
|
return resolveAllelePair(locus, g[locus])
|
|
}
|
|
|
|
/**
|
|
* Whether a catalog entry belongs to the given E-family by NAME. The Schimmel
|
|
* entries split into two breeder groups that share the same E:'ef' token but
|
|
* differ by zygosity of the live animal:
|
|
* - 'Fuchsschimmel' family (ef/e het) → only *fuchsschimmel entries
|
|
* (Goldfuchsschimmel, Kohlfuchsschimmel, …).
|
|
* - 'Schimmel' family (ef/ef hom) → the pure roan entries whose name ends
|
|
* in 'schimmel' but NOT 'fuchsschimmel' (Rotaugenschimmel, Orangeschimmel,
|
|
* Silberschimmel, …).
|
|
* GEN-5 (ticket 5826e8e2): this is why ef/e must NOT match a pure-Schimmel entry
|
|
* (Rotaugenschimmel) — a het Fuchsschimmel animal is a Goldfuchsschimmel.
|
|
*/
|
|
function entryInEFamily(entry: FarbschlagEntry, family: string): boolean {
|
|
if (entry.tokens.E === undefined) return false
|
|
const n = entry.name.toLowerCase()
|
|
if (family === 'Fuchsschimmel') return n.includes('fuchsschimmel')
|
|
if (family === 'Schimmel') return n.includes('schimmel') && !n.includes('fuchsschimmel')
|
|
// 'Fuchs' family: fox entries are E:'e' (no 'schimmel' in the name).
|
|
return !n.includes('schimmel')
|
|
}
|
|
|
|
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
|
function baseColourFor(g: Genotype): string | null {
|
|
const family = eFamily(g)
|
|
const base = family
|
|
? (BASE_COLORS.find((e) => entryInEFamily(e, family) && matches(g, e)) ?? null)
|
|
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
|
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
|
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
|
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
|
|
return base?.name ?? null
|
|
}
|
|
|
|
/**
|
|
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
|
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
|
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
|
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
|
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
|
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
|
|
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
|
|
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
|
|
*/
|
|
function colourpointName(g: Genotype): string | null {
|
|
const c = resolvedPair(g, 'C')
|
|
if (c.includes('C')) return null // a full C allele => full colour, no CP
|
|
if (c[0] === 'ch' && c[1] === 'ch') return null // chch -> base matcher (Hermelin/Himalaya)
|
|
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
|
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
|
const agouti = resolvedPair(g, 'A').includes('A')
|
|
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
|
|
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
|
|
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
|
|
// must derive its base generically like the A- branch, so e.g.
|
|
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
|
|
if (!agouti && eFamily(g) === null) {
|
|
const [g1, g2] = resolvedPair(g, 'G')
|
|
const grey = g1 === 'g' && g2 === 'g'
|
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
|
return bothCchm ? 'Marder' : 'Siam'
|
|
}
|
|
// Colourpoint base derivation: name the colour as if C were full, then prefix
|
|
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
|
|
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
|
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
|
if (!base) return null
|
|
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
|
const DILUTE = 'Dilute '
|
|
if (base.startsWith(DILUTE)) {
|
|
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
|
}
|
|
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
|
}
|
|
|
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
|
const modifiers: string[] = []
|
|
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
|
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
|
|
|
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
|
|
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
|
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
|
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
|
// ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here)
|
|
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
|
const [c0, c1] = resolvedPair(g, 'C')
|
|
const [p0, p1] = resolvedPair(g, 'P')
|
|
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
|
|
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
|
|
const name = ['REW', ...modifiers].join(' ')
|
|
return { name, base: null, unknown: false }
|
|
}
|
|
|
|
const baseName = colourpointName(g) ?? baseColourFor(g)
|
|
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
|
|
// a computed Farbschlag output. If baseName is a category label, treat as
|
|
// Unbekannt instead of leaking an invalid name into the UI.
|
|
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
|
|
'Standard', 'Colourpoint', 'Dilute',
|
|
'Fuchs', 'Fuchsschimmel', 'Schimmel',
|
|
'Colourpoint Dilute',
|
|
])
|
|
if (!baseName || CATEGORY_NAMES.has(baseName)) {
|
|
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
|
}
|
|
const name = [baseName, ...modifiers].join(' ')
|
|
return { name, base: null, unknown: false }
|
|
}
|
|
|
|
/**
|
|
* Convenience: genotype -> German Farbschlag name (with Schecke/Rex modifiers),
|
|
* or "Unbekannter Farbschlag". This is the DENORMALIZATION contract for DATA-1:
|
|
* the backend mirrors this mapping into a stored Farbschlag column (recomputed
|
|
* whenever the genotype changes) so Gridify list filtering/sorting never has to
|
|
* parse genotype strings in SQL. The catalog (BASE_COLORS) is self-contained in
|
|
* this file so it can be ported/kept in sync with the backend.
|
|
*/
|
|
export function genotypeToFarbschlag(g: Genotype): string {
|
|
return farbschlagFor(g).name
|
|
}
|
|
|
|
export function formatVarietyName(baseName: string, genotypeStr: string | null | undefined): string {
|
|
if (!baseName) return ''
|
|
if (!genotypeStr?.trim()) return baseName
|
|
try {
|
|
const g = fromDisplayString(genotypeStr)
|
|
const modifiers: string[] = []
|
|
// Einen Modifier NICHT doppeln, wenn der Katalogname ihn schon traegt: Bestaende mit
|
|
// Namen wie „Schwarz Schecke" ergaeben sonst „Schwarz Schecke Schecke" — und der
|
|
// Vergleich mit dem errechneten Farbschlag meldet dann eine Abweichung, die es nicht gibt.
|
|
const words = baseName.toLowerCase().split(/[\s-]+/)
|
|
const alreadyNamed = (modifier: string) => words.includes(modifier.toLowerCase())
|
|
if (locusToken(g, 'Sp') === 'Sp' && !alreadyNamed('Schecke')) modifiers.push('Schecke')
|
|
if (locusToken(g, 'Re') === 'Re' && !alreadyNamed('Rex')) modifiers.push('Rex')
|
|
return [baseName, ...modifiers].join(' ')
|
|
} catch {
|
|
return baseName
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A representative full genotype for a catalog entry: each specified locus is
|
|
* homozygous for its token allele; unspecified loci take the wild-type allele.
|
|
* This is the entry's CanonicalGenotype for DB seeding.
|
|
*/
|
|
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
|
const base = wildType()
|
|
const out = {} as Record<LocusKey, AllelePair>
|
|
// GEN-5 (ticket 5826e8e2): a *Fuchsschimmel variety is the HETEROZYGOUS ef/e
|
|
// animal (a Schimmel-modified Fox), whereas the pure *schimmel varieties
|
|
// (Rotaugen-/Orange-/Silberschimmel) are HOMOZYGOUS ef/ef. The E token is the
|
|
// shared phenotype letter 'ef'; the representative genotype must encode the
|
|
// right zygosity so each entry round-trips back to its own family.
|
|
const isFuchsschimmel = entry.name.toLowerCase().includes('fuchsschimmel')
|
|
for (const locus of LOCUS_ORDER) {
|
|
const token = entry.tokens[locus]
|
|
if (!token) {
|
|
out[locus] = base[locus]
|
|
continue
|
|
}
|
|
if (locus === 'E' && token === 'ef' && isFuchsschimmel) {
|
|
out[locus] = ['ef', 'e'] // het Fuchsschimmel (ef/e), not hom ef/ef
|
|
continue
|
|
}
|
|
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
|
|
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
|
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
|
out[locus] = [a, b]
|
|
}
|
|
return makeGenotype(out)
|
|
}
|
|
|
|
/**
|
|
* DB seed view for DATA-2's ColorVariety table. Each row mirrors the table
|
|
* shape (Name, CanonicalGenotype, SortOrder, ImageFile). SortOrder = position.
|
|
*
|
|
* IMPORTANT: `name` values become DB keys the UI filters on — renames are
|
|
* BREAKING changes and must be routed through god. Adding new varieties is safe.
|
|
*/
|
|
export interface ColorVarietySeed {
|
|
readonly name: string
|
|
readonly english?: string
|
|
readonly canonicalGenotype: string
|
|
readonly sortOrder: number
|
|
readonly image?: string
|
|
}
|
|
|
|
export const CATALOG: readonly ColorVarietySeed[] = BASE_COLORS.map((entry, i) => ({
|
|
name: entry.name,
|
|
english: entry.english,
|
|
canonicalGenotype: toDisplayString(representativeGenotype(entry)),
|
|
sortOrder: i,
|
|
image: entry.image,
|
|
}))
|
|
|
|
/** Number of catalogued varieties. */
|
|
export const CATALOG_SIZE = BASE_COLORS.length
|
|
|
|
/** Public path to a Farbschlag image (served from gerbil-manager-web/public). */
|
|
export function farbschlagImageUrl(image: string | undefined): string | null {
|
|
return image ? `/farbschlaege/${image}` : null
|
|
}
|
|
|
|
/** Look up a catalog entry by exact variety name. */
|
|
export function findVariety(name: string): FarbschlagEntry | undefined {
|
|
return BASE_COLORS.find((e) => e.name === name)
|
|
}
|