Compare commits
1 Commits
feature/na
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| 08500757d7 |
@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
|
|||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||||
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
/// fence strip), 503-not-configured path, upstream-error path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NameSuggestionTests
|
public class NameSuggestionTests
|
||||||
{
|
{
|
||||||
@@ -26,14 +26,6 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("origin", prompt);
|
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]
|
[Fact]
|
||||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||||
{
|
{
|
||||||
@@ -41,10 +33,7 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("6", prompt);
|
Assert.Contains("6", prompt);
|
||||||
Assert.Contains("\"A\"", prompt);
|
Assert.Contains("\"A\"", prompt);
|
||||||
Assert.Contains("weibliche", prompt);
|
Assert.Contains("weibliche", prompt);
|
||||||
// codes are mapped to German descriptions
|
Assert.Contains("norn,mythg", prompt);
|
||||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
|
||||||
Assert.Contains("Griechische Mythologie", prompt);
|
|
||||||
Assert.Contains("Kulturkreisen", prompt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -65,67 +54,6 @@ namespace GerbilManager.Tests
|
|||||||
Assert.DoesNotContain("männliche", prompt);
|
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 ──────────────────────────────────────────────────────
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -20,23 +20,6 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
PropertyNameCaseInsensitive = true,
|
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(
|
public async Task<NameSuggestionResult> SuggestAsync(
|
||||||
string? letter, string? gender, string? usages, int count,
|
string? letter, string? gender, string? usages, int count,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -61,11 +44,7 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
"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)
|
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||||
{
|
{
|
||||||
@@ -76,44 +55,10 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||||
|
|
||||||
bool hasThematic = false;
|
|
||||||
if (!string.IsNullOrWhiteSpace(usages))
|
if (!string.IsNullOrWhiteSpace(usages))
|
||||||
{
|
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||||
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||||
var etymDescriptions = new List<string>();
|
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||||
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($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ 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 } from '../genetics'
|
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user