feat(stammbaum): dim connectors outside the highlighted lineage (#703)

StammbaumConnectors gains an isConnectorActive(a, b) predicate prop and
wraps each logical connector in a <g opacity> group. A connector is full
strength only when both joined people are active; otherwise it dims to
DIMMED_OPACITY. The shared parent-pair drop+bar keys on both parents,
while each child vertical keys on both parents AND that child — so the
bar stays lit to a lineage child yet dims to a collateral sibling on the
same row. Defaults to always-active, so no highlight means no dimming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-31 16:30:29 +02:00
parent f6da95014e
commit 9f5d7b8570

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { type Layout, NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout'; import { type Layout, 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'; import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -8,9 +9,20 @@ type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props { interface Props {
edges: RelationshipDTO[]; edges: RelationshipDTO[];
positions: Layout['positions']; positions: Layout['positions'];
/**
* Whether the connector joining two people is active (full strength). A
* connector is active only when both endpoints are active; otherwise it is
* dimmed. Defaults to always-active, so no lineage highlight means no dimming.
*/
isConnectorActive?: (aId: string, bId: string) => boolean;
} }
let { edges, positions }: Props = $props(); let { edges, positions, isConnectorActive = () => true }: Props = $props();
/** SVG group opacity for a connector: full when active, dimmed otherwise. */
function connectorOpacity(active: boolean): number | undefined {
return active ? undefined : DIMMED_OPACITY;
}
function nodeCenter(id: string): { x: number; y: number } | null { function nodeCenter(id: string): { x: number; y: number } | null {
const p = positions.get(id); const p = positions.get(id);
@@ -104,26 +116,45 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const xs = childCenters.map((c) => c.x)} {@const xs = childCenters.map((c) => c.x)}
{@const minX = Math.min(midX, ...xs)} {@const minX = Math.min(midX, ...xs)}
{@const maxX = Math.max(midX, ...xs)} {@const maxX = Math.max(midX, ...xs)}
<line {@const pairActive = isConnectorActive(group.parentA, group.parentB)}
x1={midX} <!-- Drop from the spouse midpoint to the sibling bar: joins the parent pair. -->
y1={parentBottomY} <g class="lineage-fade" opacity={connectorOpacity(pairActive)}>
x2={midX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if minX !== maxX}
<line x1={minX} y1={barY} x2={maxX} y2={barY} stroke="var(--c-primary)" stroke-width="1.5" />
{/if}
{#each childCenters as cc, i (group.childIds[i])}
<line <line
x1={cc.x} x1={midX}
y1={barY} y1={parentBottomY}
x2={cc.x} x2={midX}
y2={childTopY} y2={barY}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
/> />
{#if minX !== maxX}
<line
x1={minX}
y1={barY}
x2={maxX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
</g>
{#each childCenters as cc, i (group.childIds[i])}
<!-- Each vertical joins the parent pair to one child: active only when
both parents and that child are active, so the same bar can stay lit
to the lineage child while dimming to a collateral sibling. -->
{@const childActive =
isConnectorActive(group.parentA, group.childIds[i]) &&
isConnectorActive(group.parentB, group.childIds[i])}
<g class="lineage-fade" opacity={connectorOpacity(childActive)}>
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
</g>
{/each} {/each}
{/if} {/if}
{/each} {/each}
@@ -136,32 +167,35 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const parentBottomY = parentCenter.y + NODE_H / 2} {@const parentBottomY = parentCenter.y + NODE_H / 2}
{@const childTopY = childCenter.y - NODE_H / 2} {@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2} {@const barY = (parentBottomY + childTopY) / 2}
<line {@const active = isConnectorActive(link.parentId, link.childId)}
x1={parentCenter.x} <g class="lineage-fade" opacity={connectorOpacity(active)}>
y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line <line
x1={parentCenter.x} x1={parentCenter.x}
y1={barY} y1={parentBottomY}
x2={childCenter.x} x2={parentCenter.x}
y2={barY} y2={barY}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
/> />
{/if} {#if parentCenter.x !== childCenter.x}
<line <line
x1={childCenter.x} x1={parentCenter.x}
y1={barY} y1={barY}
x2={childCenter.x} x2={childCenter.x}
y2={childTopY} y2={barY}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
/> />
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
</g>
{/if} {/if}
{/each} {/each}
@@ -170,20 +204,35 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const aCenter = nodeCenter(e.personId)} {@const aCenter = nodeCenter(e.personId)}
{@const bCenter = nodeCenter(e.relatedPersonId)} {@const bCenter = nodeCenter(e.relatedPersonId)}
{#if aCenter && bCenter} {#if aCenter && bCenter}
<line {@const active = isConnectorActive(e.personId, e.relatedPersonId)}
x1={aCenter.x} <g class="lineage-fade" opacity={connectorOpacity(active)}>
y1={aCenter.y} <line
x2={bCenter.x} x1={aCenter.x}
y2={bCenter.y} y1={aCenter.y}
stroke="var(--c-primary)" x2={bCenter.x}
stroke-width="1.5" y2={bCenter.y}
stroke-dasharray={e.toYear ? '4 4' : undefined} stroke="var(--c-primary)"
/> stroke-width="1.5"
<circle stroke-dasharray={e.toYear ? '4 4' : undefined}
cx={(aCenter.x + bCenter.x) / 2} />
cy={(aCenter.y + bCenter.y) / 2} <circle
r="6" cx={(aCenter.x + bCenter.x) / 2}
fill="var(--c-primary)" cy={(aCenter.y + bCenter.y) / 2}
/> r="6"
fill="var(--c-primary)"
/>
</g>
{/if} {/if}
{/each} {/each}
<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>