feat(stammbaum): render cross-level links with a distinct dash (#724)

StammbaumConnectors takes the layout's crossLinks and draws those parent->child
connectors with a 2 6 dash at reduced opacity — deliberately distinct from the
ended-marriage spouse dash (4 4) and from a solid parent drop. Geometry still
lands on the child top, so the meaning is carried redundantly (WCAG 1.4.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-04 13:32:04 +02:00
parent 2b8864cd6f
commit d703c99c25
2 changed files with 33 additions and 1 deletions

View File

@@ -9,6 +9,13 @@ type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
edges: RelationshipDTO[];
positions: Layout['positions'];
/**
* Displaced parent→child edges (cross-level intra-family marriages): the
* child lives in a spouse's run elsewhere, so the connector is drawn with a
* distinct dash so it never reads as a normal parent drop or an ended
* marriage. Geometry still lands on the child, so meaning is redundant.
*/
crossLinks?: Layout['crossLinks'];
/**
* Whether the connector joining two people is active (full strength). A
* connector is active only when both endpoints are active; otherwise it is
@@ -17,7 +24,18 @@ interface Props {
isConnectorActive?: (aId: string, bId: string) => boolean;
}
let { edges, positions, isConnectorActive = () => true }: Props = $props();
let { edges, positions, crossLinks = [], isConnectorActive = () => true }: Props = $props();
// Dash cadence + opacity for a cross-link, deliberately distinct from the
// ended-marriage spouse dash (`4 4`) so the two never read alike (WCAG 1.4.1:
// geometry carries the meaning too, not stroke alone).
const CROSS_LINK_DASH = '2 6';
const CROSS_LINK_OPACITY = 0.55;
const crossLinkSet = $derived(new SvelteSet(crossLinks.map((c) => `${c.parentId}->${c.childId}`)));
function isCrossLink(parentId: string, childId: string): boolean {
return crossLinkSet.has(`${parentId}->${childId}`);
}
/** SVG group opacity for a connector: full when active, dimmed otherwise. */
function connectorOpacity(active: boolean): number | undefined {
@@ -149,6 +167,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
centres (a child without a position drops out of both together). -->
{@const childActive =
isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)}
{@const childCross =
isCrossLink(group.parentA, cc.id) || isCrossLink(group.parentB, cc.id)}
<g class="lineage-fade" opacity={connectorOpacity(childActive)}>
<line
x1={cc.x}
@@ -157,6 +177,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={childCross ? CROSS_LINK_DASH : undefined}
stroke-opacity={childCross ? CROSS_LINK_OPACITY : undefined}
/>
</g>
{/each}
@@ -172,6 +194,9 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
{@const active = isConnectorActive(link.parentId, link.childId)}
{@const cross = isCrossLink(link.parentId, link.childId)}
{@const dash = cross ? CROSS_LINK_DASH : undefined}
{@const strokeOpacity = cross ? CROSS_LINK_OPACITY : undefined}
<g class="lineage-fade" opacity={connectorOpacity(active)}>
<line
x1={parentCenter.x}
@@ -180,6 +205,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={dash}
stroke-opacity={strokeOpacity}
/>
{#if parentCenter.x !== childCenter.x}
<line
@@ -189,6 +216,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={dash}
stroke-opacity={strokeOpacity}
/>
{/if}
<line
@@ -198,6 +227,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={dash}
stroke-opacity={strokeOpacity}
/>
</g>
{/if}

View File

@@ -309,6 +309,7 @@ function handleCanvasKey(event: KeyboardEvent) {
<StammbaumConnectors
edges={edges}
positions={layout.positions}
crossLinks={layout.crossLinks}
isConnectorActive={isConnectorActive}
/>