/** * 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 { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci' import { makeGenotype, toDisplayString, wildType, WILDCARD, 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> /** 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', 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', 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', 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' }, { 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-', het -> 'CP--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 { // Default an unknown allele to the WILD-TYPE reading: most-dominant for the // colour loci (unknown-C => full-colour 'C', not a white), but the recessive // UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke). const alleles = LOCI[locus].alleles const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls' const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0] const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a)) 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) } function matches(g: Genotype, entry: FarbschlagEntry): boolean { return (Object.keys(entry.tokens) as LocusKey[]).every( (locus) => locusToken(g, locus) === entry.tokens[locus], ) } /** * 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 { const [x, y] = 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' if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs' return null } /** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */ /** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */ function resolvedPair(g: Genotype, locus: LocusKey): [string, string] { const alleles = LOCI[locus].alleles const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls' const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0] const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a)) return [x, y] } /** 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) => e.tokens.E !== undefined && 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- | A- cchm/ch -> CP--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') if (!agouti) { const [g1, g2] = resolvedPair(g, 'G') const grey = g1 === 'g' && g2 === 'g' if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell' return bothCchm ? 'Marder' : 'Siam' } // A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix. 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 = 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 } /** * 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 for (const locus of LOCUS_ORDER) { const token = entry.tokens[locus] if (!token) { out[locus] = base[locus] 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) }