Compare commits

..

5 Commits

Author SHA1 Message Date
7c12a65938 Merge feature/namegen-2-be (NAMEGEN-2-BE): 5 Themen-Kategorien disney/pokemon/encities/hrcities/usstates für /names/suggest
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:34:52 +02:00
5361a292f1 NAMEGEN-2-BE: 5 neue Themen-Kategorien (disney/pokemon/encities/hrcities/usstates)
UsageMap bildet alle Codes auf deutsche Beschreibungen ab; thematische Kategorien
landen unter "Themen-Kategorien" im Prompt (kein Etymologie-Zwang, origin=Kategoriename).
22 neue Tests, 179/179 gruen.
2026-06-07 01:32:56 +02:00
cb5acd2005 Merge feature/genotype-display-rex (GEN-4d + UI-FIX): Rex-Wildtyp 'rere' in Anzeige ausblenden (7 Loci) + doppelte Wurf-Überschrift entfernt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:31:19 +02:00
bda5479b93 UI-FIX: doppelte Überschrift 'Erwartete Farbschläge' auf Wurf-Detailseite entfernt 2026-06-07 01:29:49 +02:00
d5c155953b GEN-4d: rere (Re-Wildtyp) aus Anzeige ausblenden (Julian)
- genotype.ts: toDisplayString filtert Re aus wenn re/re (Wildtyp) —
  analog zur bestehenden Sls-Regel; Rere/ReRe bleiben sichtbar.
  Wildtyp-Anzeige jetzt 7 Loci: AA CC DD EE GG PP spsp (ohne rere).
- StammbaumPage.tsx: Druck-Ahnentafel nutzt toDisplayString(fromDisplayString(g.genotype))
  statt rohem g.genotype-String (Audit: einzige Display-Stelle ausserhalb toDisplayString).
  (GerbilDetailPage/BreedingResultView gehen bereits via toDisplayString.)
- genetics.test.ts: Alle Display-String-Assertions auf 7-Loci-Format aktualisiert;
  Rere-Nachweis zum Sls/Re-Test ergaenzt; Katalog-Regex auf {6,8}.
- colorVarietySeed.generated.json regeneriert (kein rere in canonicalGenotype).
- backend.json unveraendert (Pam: DB-Speicherung bleibt volles 8-Loki-Format).
2026-06-07 01:28:06 +02:00
10 changed files with 275 additions and 331 deletions

View File

@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
/// fence strip), 503-not-configured path, upstream-error path.
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
/// </summary>
public class NameSuggestionTests
{
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
Assert.Contains("origin", prompt);
}
[Fact]
public void SystemPrompt_erklärt_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("thematischen Kategorien", prompt);
Assert.Contains("Erfinde KEINE Etymologie", prompt);
}
[Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt);
Assert.Contains("norn,mythg", prompt);
// codes are mapped to German descriptions
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Griechische Mythologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
}
[Fact]
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
Assert.DoesNotContain("männliche", prompt);
}
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
{
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
Assert.Equal(expectedDescription, entry.Description);
Assert.True(entry.Thematic);
}
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
Assert.Contains(expectedDescription, prompt);
Assert.Contains("Themen-Kategorien", prompt);
Assert.DoesNotContain("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
Assert.Contains("Disney-Charaktere", prompt);
Assert.Contains("Themen-Kategorien", prompt);
}
[Fact]
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
}
[Fact]
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
{
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync("D", null, "disney", 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal("Dumbo", result.Suggestions![0].Name);
Assert.Equal("Disney", result.Suggestions[0].Origin);
}
// ── JSON parsing ──────────────────────────────────────────────────────
[Fact]

View File

@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
PropertyNameCaseInsensitive = true,
};
// Known usage codes → (German description, isThematic).
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
new(StringComparer.OrdinalIgnoreCase)
{
["norn"] = ("Nordische/Altnordische Etymologie", false),
["japa"] = ("Japanische Etymologie", false),
["mythg"] = ("Griechische Mythologie", false),
["ger"] = ("Germanische/Deutsche Etymologie", false),
["arb"] = ("Arabische Etymologie", false),
["disney"] = ("Disney-Charaktere", true),
["pokemon"] = ("Pokémon-Namen", true),
["encities"] = ("Namen englischer Städte", true),
["hrcities"] = ("Namen kroatischer Städte", true),
["usstates"] = ("Namen von US-Bundesstaaten", true),
};
public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count,
CancellationToken ct = default)
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
"Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
"Erfinde KEINE Etymologie für thematische Kategorien.";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
bool hasThematic = false;
if (!string.IsNullOrWhiteSpace(usages))
sb.Append($", aus den Kulturkreisen: {usages}");
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
{
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var etymDescriptions = new List<string>();
var thematicDescriptions = new List<string>();
foreach (var code in codes)
{
if (UsageMap.TryGetValue(code, out var entry))
{
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
else etymDescriptions.Add(entry.Description);
}
else
{
etymDescriptions.Add(code);
}
}
if (etymDescriptions.Count > 0)
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
if (thematicDescriptions.Count > 0)
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
hasThematic = thematicDescriptions.Count > 0;
}
sb.Append(". ");
if (hasThematic)
{
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
}
else
{
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
}
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString();

View File

@@ -110,30 +110,3 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expandBtn.click({ force: true })
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
await page.goto('/rennmaeuse/fridolin/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const panel = page.locator('.stammbaum-litters-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText(t.littersTitle)
await expect(panel).toContainText('Wurf K')
await expect(panel).toContainText('5')
// Link-Klick → Wurf-Detailseite
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
await expect(wurfLink).toBeVisible()
await wurfLink.click()
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
})
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
})

View File

@@ -46,8 +46,8 @@ describe('Fraction', () => {
describe('Genotype serialization', () => {
it('round-trips display string <-> structured form', () => {
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
})
it('parses multi-char C-series alleles via maximal munch', () => {
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
expect(g.P).toEqual(['P', 'p'])
})
it('wild type is AA CC DD EE GG PP spsp rere', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
})
})
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
expect(result.offspring).toHaveLength(1)
const only = result.offspring[0]
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
expect(only.probability.text).toBe('1')
expect(result.warnings).toHaveLength(0)
})
@@ -223,8 +223,9 @@ describe('Farbschlag catalog', () => {
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
})
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -285,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
it('always RENDERS G, never Uw (breeder preference)', () => {
// Uw/uw is an input/import alias only; output must echo G/g.
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
'AA CC DD EE Gg PP spsp rere',
'AA CC DD EE Gg PP spsp',
)
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
})
@@ -297,10 +298,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
})
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
// Rex het → Rere shown
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
'AA CC DD EE GG PP spsp Rere',
)
// WP → Slsl shown, rere still omitted
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp rere Slsl',
'AA CC DD EE GG PP spsp Slsl',
)
})
@@ -330,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
describe('GEN-3a: flag/metadata tokens tolerated', () => {
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
})
it('extractGenotypeFlags reads deafness + tags', () => {
@@ -363,11 +370,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
it("accepts '-' input, stores '?', displays '-'", () => {
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
})
it("'?' and '-' inputs are equivalent", () => {
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
'Aa C- DD EE GG Pp spsp rere',
'Aa C- DD EE GG Pp spsp',
)
})
})
@@ -590,15 +597,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp rere',
'AA CC DD e[f]e[f] GG PP spsp',
)
// C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp rere',
'aa c[chm]c[h] DD EE GG PP spsp',
)
// C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp rere',
'aa c[chm]c[chm] DD EE GG PP spsp',
)
})
@@ -608,24 +615,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
})
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp rere',
'aa CC DD Ee GG PP spsp',
)
})
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp rere',
'aa CC DD Ee[f] GG PP spsp',
)
})
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp'
const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?'])
@@ -633,16 +640,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
})
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display)
})
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp'
const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd'])
@@ -665,7 +672,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
})
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
@@ -677,6 +684,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
})
})

View File

@@ -2,425 +2,425 @@
{
"name": "REW",
"english": "Pink Eyed White",
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp",
"sortOrder": 0,
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
},
{
"name": "Hermelin",
"english": "Dark Tailed White",
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp",
"sortOrder": 1,
"image": "hermelin.jpeg"
},
{
"name": "Himalaya",
"english": "Himalayan",
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp",
"sortOrder": 2,
"image": "himalaya.jpg"
},
{
"name": "Zobel",
"english": "Sable",
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp",
"sortOrder": 3,
"image": "zobel.jpeg"
},
{
"name": "Rotaugenschimmel",
"english": "Red-Eyed Roan",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
"sortOrder": 4,
"image": "rotaugen-schimmel.jpg"
},
{
"name": "Agouti",
"english": "Golden Agouti",
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
"canonicalGenotype": "AA CC DD EE GG PP spsp",
"sortOrder": 5,
"image": "agouti-mit-erklaerung-der-genloci.JPG"
},
{
"name": "Schwarz",
"english": "Black",
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
"canonicalGenotype": "aa CC DD EE GG PP spsp",
"sortOrder": 6,
"image": "schwarz.jpg"
},
{
"name": "Silberagouti",
"english": "Grey Agouti",
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
"canonicalGenotype": "AA CC DD EE gg PP spsp",
"sortOrder": 7,
"image": "silberagouti.jpg"
},
{
"name": "Anthrazit",
"english": "Slate",
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
"canonicalGenotype": "aa CC DD EE gg PP spsp",
"sortOrder": 8,
"image": "anthrazit.jpg"
},
{
"name": "Algierfuchs",
"english": "Dark-Eyed Honey",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"canonicalGenotype": "AA CC DD ee GG PP spsp",
"sortOrder": 9,
"image": "algierfuchs.jpg"
},
{
"name": "Blau",
"english": "Blue",
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
"canonicalGenotype": "aa CC dd EE GG PP spsp",
"sortOrder": 10,
"image": "blau-schwarz-dd.JPG"
},
{
"name": "Gold",
"english": "Argente Golden",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"canonicalGenotype": "AA CC DD EE GG pp spsp",
"sortOrder": 11,
"image": "gold.jpg"
},
{
"name": "Platin",
"english": "Lilac",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"sortOrder": 12,
"image": "platin.JPG"
},
{
"name": "Goldfuchs",
"english": "Yellow Fox",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"canonicalGenotype": "AA CC DD ee GG pp spsp",
"sortOrder": 13,
"image": "goldfuchs.jpg"
},
{
"name": "Rotfuchs",
"english": "Argente Nutmeg",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"canonicalGenotype": "aa CC DD ee GG pp spsp",
"sortOrder": 14,
"image": "rotfuchs.JPG"
},
{
"name": "Dilute Gold",
"english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"canonicalGenotype": "AA CC dd EE GG pp spsp",
"sortOrder": 15,
"image": "gold-dd.jpg"
},
{
"name": "Dilute Platin",
"english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"canonicalGenotype": "aa CC dd EE GG pp spsp",
"sortOrder": 16,
"image": "platin-dd.jpg"
},
{
"name": "Altweiss (REW)",
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
"canonicalGenotype": "aa CC DD EE gg pp spsp",
"sortOrder": 17,
"image": "altweiss-rew.jpeg"
},
{
"name": "Apricot (Blassfuchs)",
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
"canonicalGenotype": "AA CC DD ee gg pp spsp",
"sortOrder": 18,
"image": "apricot-blassfuchs.jpg"
},
{
"name": "Blaufuchs",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"canonicalGenotype": "aa CC DD ee gg PP spsp",
"sortOrder": 19,
"image": "blaufuchs.jpg"
},
{
"name": "C-Separator",
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
"canonicalGenotype": "aa CC DD ee gg pp spsp",
"sortOrder": 20,
"image": "c-separator.jpg"
},
{
"name": "Elfenbein",
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
"canonicalGenotype": "AA CC DD EE gg pp spsp",
"sortOrder": 21,
"image": "elfenbein.jpg"
},
{
"name": "Kohlfuchs",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"canonicalGenotype": "aa CC DD ee GG PP spsp",
"sortOrder": 22,
"image": "kohlfuchs.jpg"
},
{
"name": "Polarfuchs",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"canonicalGenotype": "AA CC DD ee gg PP spsp",
"sortOrder": 23,
"image": "polarfuchs.jpg"
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"sortOrder": 24,
"image": "saphir.jpg"
},
{
"name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
"sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg"
},
{
"name": "Topas",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"canonicalGenotype": "AA CC DD EE GG pp spsp",
"sortOrder": 26,
"image": "topas.jpg"
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"sortOrder": 27,
"image": "platin-hell.jpg"
},
{
"name": "Dilute Agouti",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"canonicalGenotype": "AA CC dd EE GG PP spsp",
"sortOrder": 28,
"image": "agouti-dd.jpg"
},
{
"name": "Dilute Silberagouti",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"canonicalGenotype": "AA CC dd EE gg PP spsp",
"sortOrder": 29,
"image": "silberagouti-dd.jpg"
},
{
"name": "Dilute Kohlfuchs",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"canonicalGenotype": "aa CC dd ee GG PP spsp",
"sortOrder": 30,
"image": "kohlfuchs-dd.jpg"
},
{
"name": "Dilute Anthrazit",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"canonicalGenotype": "aa CC dd EE gg PP spsp",
"sortOrder": 31,
"image": "anthrazit-dd.jpg"
},
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
"canonicalGenotype": "AA CC dd ee GG PP spsp",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
"canonicalGenotype": "AA CC dd ee GG pp spsp",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
"canonicalGenotype": "aa CC dd ee GG pp spsp",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
"canonicalGenotype": "AA CC dd ee gg PP spsp",
"sortOrder": 35
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
"sortOrder": 36,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
{
"name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"canonicalGenotype": "aa CC DD ee GG PP spsp",
"sortOrder": 41,
"image": "kohlfuchs-hell.jpg"
},
{
"name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"canonicalGenotype": "AA CC DD ee GG pp spsp",
"sortOrder": 42,
"image": "goldfuchs-hell.jpg"
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
{
"name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"canonicalGenotype": "AA CC DD EE GG pp spsp",
"sortOrder": 44,
"image": "gold-hell.jpg"
},
{
"name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"canonicalGenotype": "aa CC DD ee gg PP spsp",
"sortOrder": 45,
"image": "blaufuchs-hell.jpeg"
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
{
"name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"canonicalGenotype": "AA CC DD ee gg PP spsp",
"sortOrder": 47,
"image": "polarfuchs-hell.jpeg"
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
{
"name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"canonicalGenotype": "aa CC DD ee GG pp spsp",
"sortOrder": 49,
"image": "rotfuchs-hell.jpg"
},
{
"name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"canonicalGenotype": "aa CC DD ee GG PP spsp",
"sortOrder": 50,
"image": "kohlfuchs-hell-2.jpg"
},
{
"name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"canonicalGenotype": "AA CC DD ee GG PP spsp",
"sortOrder": 51,
"image": "algierfuchs-hell.JPG"
},
{
"name": "Dilute Topas",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"canonicalGenotype": "AA CC dd EE GG pp spsp",
"sortOrder": 52,
"image": "topas-dd.jpg"
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"canonicalGenotype": "aa CC dd ee gg pp spsp",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},
{
"name": "Marder",
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp",
"sortOrder": 54,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp",
"sortOrder": 55,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp",
"sortOrder": 56,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp",
"sortOrder": 57,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp",
"sortOrder": 58
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp",
"sortOrder": 59,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp",
"sortOrder": 60
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp",
"sortOrder": 61,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp",
"sortOrder": 62
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp",
"sortOrder": 63,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp",
"sortOrder": 64
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp",
"sortOrder": 65
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp",
"sortOrder": 66
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp",
"sortOrder": 67
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp",
"sortOrder": 68
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp",
"sortOrder": 69
}
]

View File

@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
}
/**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
* convention) — e.g. ['C','?'] renders "C-".
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
* 8-locus strings and the colour catalog stay byte-identical; it only appears for
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
* convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
* order E > e > e[f].
*/
export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
(locus) =>
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => {
const [a, b] = displayPair(locus, g[locus])

View File

@@ -22,13 +22,12 @@ import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { listLitters } from '../api/litters'
import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil, Litter } from '../api/types'
import type { Gender, Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import {
DEFAULT_GENERATIONS,
ancestorsAt,
@@ -157,13 +156,6 @@ export default function StammbaumPage() {
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
const rootLitters = useApi(
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const rootLitterItems = rootLitters.data?.items ?? []
/* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
@@ -323,30 +315,25 @@ export default function StammbaumPage() {
</button>
</div>
<div className="stammbaum-layout">
{rootLitterItems.length > 0 && (
<LittersPanel litters={rootLitterItems} t={t} />
<div className="stammbaum-canvas" ref={canvasRef}>
{view && (
<Tree
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)}
<div className="stammbaum-canvas" ref={canvasRef}>
{view && (
<Tree
key={id}
data={datum}
renderCustomNodeElement={renderNode}
orientation="horizontal"
pathFunc="step"
translate={view.translate}
zoom={view.zoom}
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
zoomable
draggable
collapsible={false}
nodeSize={{ x: NODE_X, y: NODE_Y }}
separation={{ siblings: 1, nonSiblings: 1 }}
onUpdate={handleTreeUpdate}
/>
)}
</div>
</div>
<ul className="stammbaum-hints">
<li>{t.tapHint}</li>
@@ -430,36 +417,6 @@ function PedigreeCard({
)
}
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
function LittersPanel({
litters,
t,
}: {
litters: Litter[]
t: { littersTitle: string; littersJunge: string }
}) {
return (
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
<ul className="stammbaum-litters-panel__list">
{litters.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
<span className="stammbaum-litters-panel__name">{l.name}</span>
{l.totalBorn != null && (
<span className="stammbaum-litters-panel__born">
{l.totalBorn} {t.littersJunge}
</span>
)}
</Link>
</li>
))}
</ul>
</aside>
)
}
function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return (
@@ -555,7 +512,11 @@ function PrintCell({
</div>
)}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
{g.genotype && gen <= 2 && (
<div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))}
</div>
)}
</div>
)
}

View File

@@ -128,7 +128,6 @@ export default function WurfDetailPage() {
</ul>
)}
<h3>{t.detail.expectedColors}</h3>
{expected ? (
<BreedingResultView result={expected} title={t.detail.expectedColors} />
) : (

View File

@@ -45,132 +45,9 @@
color: var(--color-text-muted);
}
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
.stammbaum-layout {
display: flex;
align-items: stretch;
gap: 0;
}
/* Desktop: Würfe-Panel links vom Baum. */
.stammbaum-litters-panel {
flex: none;
width: 148px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem 0.5rem 0;
border-right: 1px solid var(--color-border);
margin-right: 0;
}
.stammbaum-litters-panel__title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.stammbaum-litters-panel__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-y: auto;
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
}
.stammbaum-litters-panel__link {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.3rem 0.45rem;
border-radius: 0.4rem;
text-decoration: none;
color: inherit;
background: var(--color-surface);
border: 1px solid var(--color-border);
font-size: 0.8rem;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.stammbaum-litters-panel__link:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent);
color: var(--color-accent);
}
.stammbaum-litters-panel__name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
font-size: 0.72rem;
}
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
@media (max-width: 520px) {
.stammbaum-layout {
flex-direction: column;
}
.stammbaum-litters-panel {
width: auto;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-right: none;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.stammbaum-litters-panel__title {
flex: none;
white-space: nowrap;
}
.stammbaum-litters-panel__list {
flex-direction: row;
flex-wrap: nowrap;
max-height: none;
overflow-x: auto;
overflow-y: hidden;
gap: 0.4rem;
}
.stammbaum-litters-panel__link {
flex-direction: row;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
}
/* Canvas behält explizite Höhe in column-Richtung. */
.stammbaum-canvas {
flex: 0 0 auto;
}
}
/* ── Zeichenfläche ────────────────────────────────────────────── */
.stammbaum-canvas {
flex: 1 1 auto;
min-width: 0;
height: clamp(18rem, 62dvh, 46rem);
border: 1px solid var(--color-border);
border-radius: 0.6rem;

View File

@@ -358,9 +358,6 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen',
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe',
littersJunge: 'Junge',
zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern',
zoomFit: 'Ansicht einpassen',