Files
familienarchiv/frontend/src/lib/person/genealogy/StammbaumNode.svelte
Marcel 9c12f62345 fix(stammbaum): keep dimmed nodes opaque so connectors do not bleed through (#703)
Group opacity on the node <g> made the whole node translucent — including
its card fill — so the connector lines drawn beneath a dimmed node showed
through it. Render the card fill at full strength outside the dim group and
move the lineage focus+dim onto an inner content group (outline + labels)
only. The focus ring also leaves the dim group, so a dimmed-but-focused
node keeps a full-strength ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:12:39 +02:00

118 lines
3.0 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
import { DIMMED_OPACITY } from '$lib/person/genealogy/layout/highlightLineage';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
interface Props {
node: PersonNodeDTO;
pos: { x: number; y: number };
selected: boolean;
/** Dim the node's outline + labels when a lineage is highlighted and this person is outside it. */
dimmed?: boolean;
onSelect: (id: string) => void;
}
let { node, pos, selected, dimmed = false, onSelect }: Props = $props();
// Each node owns its own focus-ring state (the focus ring is decorative; the
// `<g role="button">` is the real focus target).
let focused = $state(false);
function handleKey(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(node.id);
}
}
const datesLabel = $derived(
node.birthYear || node.deathYear ? `, ${node.birthYear ?? '?'}${node.deathYear ?? ''}` : ''
);
</script>
<g
role="button"
tabindex="0"
aria-label="{node.displayName}{datesLabel}"
aria-expanded={selected}
transform="translate({pos.x}, {pos.y})"
onclick={() => onSelect(node.id)}
onkeydown={handleKey}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
class="cursor-pointer focus:outline-none"
>
{#if focused}
<rect
x="-3"
y="-3"
width={NODE_W + 6}
height={NODE_H + 6}
rx="6"
fill="none"
stroke="var(--c-focus-ring)"
stroke-width="2"
/>
{/if}
<!-- Opaque card fill — full strength even when dimmed, so the connectors
drawn beneath the node never bleed through. The lineage dim lives on the
content group below; group opacity here would make the fill translucent. -->
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
/>
<!-- Outline + labels carry the lineage focus+dim; the box stays opaque. -->
<g class="lineage-fade" opacity={dimmed ? DIMMED_OPACITY : undefined}>
<rect
width={NODE_W}
height={NODE_H}
rx="4"
fill="none"
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if selected}
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
{/if}
<text
x={NODE_W / 2}
y={NODE_H / 2 - 6}
text-anchor="middle"
font-family="serif"
font-size="16"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
>
{node.displayName}
</text>
{#if node.birthYear || node.deathYear}
<text
x={NODE_W / 2}
y={NODE_H / 2 + 12}
text-anchor="middle"
font-family="sans-serif"
font-size="12"
fill={selected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
opacity={selected ? 0.75 : 1}
>
{node.birthYear ?? '?'}{node.deathYear ?? ''}
</text>
{/if}
</g>
</g>
<style>
/* Ease the lineage focus+dim transition; instant for reduced-motion users. */
.lineage-fade {
transition: opacity 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.lineage-fade {
transition: none;
}
}
</style>