Files
GerbilManager/gerbil-manager-web/src/genetics/catalog.ts
Gulum efce79b3fa GEN-3g: CP colourpoint het-naming rule — '-Hell' == cchm/ch (breeder C9 answer)
- colourpointName(): A- cchm/ch now yields 'CP-<base>-Hell' (was 'CP-<base>');
  A- cchm/cchm unchanged. Non-agouti branch (Marder/Siam/Zobel/Zobel-Hell)
  unchanged — already used the het token.
- Added 5 new catalog entries: CP-Agouti-Hell, CP-Silberagouti-Hell,
  CP-Algierfuchs-Hell, CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.
  CP-Fuchs-Hell was already present. Catalog 61 -> 66.
- Regenerated colorVarietySeed.generated.json (66 rows for Pam DATA-2 re-seed).
- Tests: 82 -> 82 (reworded het case) + 1 new GEN-3g describe block. All pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:14:23 +02:00

321 lines
19 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 { 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<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: 'Pink Eyed White (PEW)', 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: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
{ name: 'dd 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 (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
{ name: 'Orangeschimmel', tokens: { 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: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
{ 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: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Blaufuchs dd', 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 stays PAIR-aware so the Fuchs/
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
* raw pair, which runs before this.)
*/
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'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
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],
)
}
/**
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
* Returns null when E is dominant (full colour) or fully unknown.
*/
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)
return base?.name ?? family
}
/**
* 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')
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'] }))
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
}
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')
const baseName = colourpointName(g) ?? baseColourFor(g)
if (!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<LocusKey, AllelePair>
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)
}