Compare commits

..

1 Commits

Author SHA1 Message Date
08500757d7 STAMMBAUM-LITTERS: Wuerfe des Wurzeltiers links im Stammbaum-Viewer
Layout: stammbaum-layout (flex-row Desktop / flex-column Mobil) wraps
[Wuerfe-Panel | Canvas]. Panel 148px breit, border-right Trenner;
auf Mobil (<=520px) horizontaler Scroll-Streifen ueber dem Canvas.

Daten: useApi(listLitters fatherId=id|motherId=id) reagiert automatisch
auf Umwurzeln (id-Param). Kein Panel wenn Wurzeltier keine Wuerfe hat.

Pro Wurf: Wurfname (fett) + N Junge + Link zu /wuerfe/{id}.
CSS: flex 0 0 auto Mobil-Override sichert Canvas-Hoehe im column-Mode.
de.ts: littersTitle, littersJunge (Stammbaum-Sektion, disjunkt).

Gate: vitest 122/122 e2e 20/20 Stammbaum (4 neue Tests x 2 Viewports)
build+tsc+eslint clean.
2026-06-07 01:37:08 +02:00
8 changed files with 324 additions and 141 deletions

View File

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

View File

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

View File

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

View File

@@ -22,12 +22,13 @@ import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree' import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de' import { de } from '../strings/de'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { listLitters } from '../api/litters'
import { listColorVarieties } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree' import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types' import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
ancestorsAt, ancestorsAt,
@@ -156,6 +157,13 @@ export default function StammbaumPage() {
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %` ? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable : 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 ── */ /* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root]) const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t]) const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
@@ -315,25 +323,30 @@ export default function StammbaumPage() {
</button> </button>
</div> </div>
<div className="stammbaum-canvas" ref={canvasRef}> <div className="stammbaum-layout">
{view && ( {rootLitterItems.length > 0 && (
<Tree <LittersPanel litters={rootLitterItems} t={t} />
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> </div>
<ul className="stammbaum-hints"> <ul className="stammbaum-hints">
<li>{t.tapHint}</li> <li>{t.tapHint}</li>
@@ -417,6 +430,36 @@ 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 }) { function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?' const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return ( return (
@@ -512,11 +555,7 @@ function PrintCell({
</div> </div>
)} )}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>} {farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && ( {g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
<div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))}
</div>
)}
</div> </div>
) )
} }

View File

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

View File

@@ -45,9 +45,132 @@
color: var(--color-text-muted); 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 ────────────────────────────────────────────── */ /* ── Zeichenfläche ────────────────────────────────────────────── */
.stammbaum-canvas { .stammbaum-canvas {
flex: 1 1 auto;
min-width: 0;
height: clamp(18rem, 62dvh, 46rem); height: clamp(18rem, 62dvh, 46rem);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.6rem; border-radius: 0.6rem;

View File

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