feat(stammbaum): mobile read path — pan, zoom, fit-to-view (#692) #694
189
frontend/src/lib/person/genealogy/StammbaumConnectors.svelte
Normal file
189
frontend/src/lib/person/genealogy/StammbaumConnectors.svelte
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { type Layout, NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
edges: RelationshipDTO[];
|
||||||
|
positions: Layout['positions'];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { edges, positions }: Props = $props();
|
||||||
|
|
||||||
|
function nodeCenter(id: string): { x: number; y: number } | null {
|
||||||
|
const p = positions.get(id);
|
||||||
|
if (!p) return null;
|
||||||
|
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
||||||
|
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
||||||
|
|
||||||
|
function pairKey(a: string, b: string): string {
|
||||||
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentLinks = {
|
||||||
|
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
|
||||||
|
// + per-child vertical pattern in the SVG.
|
||||||
|
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
|
||||||
|
// One entry per remaining parent → child edge (single parents, or the
|
||||||
|
// "second" parent edge when only one parent is in the spouse pair).
|
||||||
|
single: { key: string; parentId: string; childId: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentLinks = $derived.by<ParentLinks>(() => {
|
||||||
|
const spousePairs = new SvelteSet<string>();
|
||||||
|
for (const e of spouseEdges) {
|
||||||
|
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const childToParents = new SvelteMap<string, string[]>();
|
||||||
|
for (const e of parentEdges) {
|
||||||
|
const list = childToParents.get(e.relatedPersonId) ?? [];
|
||||||
|
list.push(e.personId);
|
||||||
|
childToParents.set(e.relatedPersonId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedMap = new SvelteMap<
|
||||||
|
string,
|
||||||
|
{ parentA: string; parentB: string; childIds: string[] }
|
||||||
|
>();
|
||||||
|
const single: ParentLinks['single'] = [];
|
||||||
|
for (const [childId, parents] of childToParents) {
|
||||||
|
const consumed = new SvelteSet<string>();
|
||||||
|
for (let i = 0; i < parents.length; i++) {
|
||||||
|
if (consumed.has(parents[i])) continue;
|
||||||
|
for (let j = i + 1; j < parents.length; j++) {
|
||||||
|
if (consumed.has(parents[j])) continue;
|
||||||
|
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
|
||||||
|
const groupKey = pairKey(parents[i], parents[j]);
|
||||||
|
const existing = sharedMap.get(groupKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.childIds.push(childId);
|
||||||
|
} else {
|
||||||
|
sharedMap.set(groupKey, {
|
||||||
|
parentA: parents[i],
|
||||||
|
parentB: parents[j],
|
||||||
|
childIds: [childId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
consumed.add(parents[i]);
|
||||||
|
consumed.add(parents[j]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const parentId of parents) {
|
||||||
|
if (consumed.has(parentId)) continue;
|
||||||
|
single.push({ key: `${parentId}->${childId}`, parentId, childId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared: ParentLinks['shared'] = [];
|
||||||
|
for (const [key, group] of sharedMap) shared.push({ key, ...group });
|
||||||
|
return { shared, single };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||||
|
bar, then short verticals from the bar to each child top. -->
|
||||||
|
{#each parentLinks.shared as group (group.key)}
|
||||||
|
{@const aCenter = nodeCenter(group.parentA)}
|
||||||
|
{@const bCenter = nodeCenter(group.parentB)}
|
||||||
|
{@const childCenters = group.childIds
|
||||||
|
.map((id) => nodeCenter(id))
|
||||||
|
.filter((c): c is { x: number; y: number } => c !== null)}
|
||||||
|
{#if aCenter && bCenter && childCenters.length > 0}
|
||||||
|
{@const midX = (aCenter.x + bCenter.x) / 2}
|
||||||
|
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
||||||
|
{@const childTopY = childCenters[0].y - NODE_H / 2}
|
||||||
|
{@const barY = (parentBottomY + childTopY) / 2}
|
||||||
|
{@const xs = childCenters.map((c) => c.x)}
|
||||||
|
{@const minX = Math.min(midX, ...xs)}
|
||||||
|
{@const maxX = Math.max(midX, ...xs)}
|
||||||
|
<line
|
||||||
|
x1={midX}
|
||||||
|
y1={parentBottomY}
|
||||||
|
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
|
||||||
|
x1={cc.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={cc.x}
|
||||||
|
y2={childTopY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
||||||
|
{#each parentLinks.single as link (link.key)}
|
||||||
|
{@const parentCenter = nodeCenter(link.parentId)}
|
||||||
|
{@const childCenter = nodeCenter(link.childId)}
|
||||||
|
{#if parentCenter && childCenter}
|
||||||
|
{@const parentBottomY = parentCenter.y + NODE_H / 2}
|
||||||
|
{@const childTopY = childCenter.y - NODE_H / 2}
|
||||||
|
{@const barY = (parentBottomY + childTopY) / 2}
|
||||||
|
<line
|
||||||
|
x1={parentCenter.x}
|
||||||
|
y1={parentBottomY}
|
||||||
|
x2={parentCenter.x}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if parentCenter.x !== childCenter.x}
|
||||||
|
<line
|
||||||
|
x1={parentCenter.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={childCenter.x}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<line
|
||||||
|
x1={childCenter.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={childCenter.x}
|
||||||
|
y2={childTopY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Spouse connectors -->
|
||||||
|
{#each spouseEdges as e (e.id)}
|
||||||
|
{@const aCenter = nodeCenter(e.personId)}
|
||||||
|
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
||||||
|
{#if aCenter && bCenter}
|
||||||
|
<line
|
||||||
|
x1={aCenter.x}
|
||||||
|
y1={aCenter.y}
|
||||||
|
x2={bCenter.x}
|
||||||
|
y2={bCenter.y}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
|
cy={(aCenter.y + bCenter.y) / 2}
|
||||||
|
r="6"
|
||||||
|
fill="var(--c-primary)"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
90
frontend/src/lib/person/genealogy/StammbaumNode.svelte
Normal file
90
frontend/src/lib/person/genealogy/StammbaumNode.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { NODE_W, NODE_H } from '$lib/person/genealogy/layout/buildLayout';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: PersonNodeDTO;
|
||||||
|
pos: { x: number; y: number };
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { node, pos, selected, 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}
|
||||||
|
<rect
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
rx="4"
|
||||||
|
fill={selected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||||
|
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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack, onMount } from 'svelte';
|
import { untrack, onMount } from 'svelte';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import {
|
import {
|
||||||
buildLayout,
|
buildLayout,
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '$lib/person/genealogy/panZoom';
|
} from '$lib/person/genealogy/panZoom';
|
||||||
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
|
||||||
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
|
import StammbaumGenerationRail from '$lib/person/genealogy/StammbaumGenerationRail.svelte';
|
||||||
|
import StammbaumConnectors from '$lib/person/genealogy/StammbaumConnectors.svelte';
|
||||||
|
import StammbaumNode from '$lib/person/genealogy/StammbaumNode.svelte';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -209,15 +211,6 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let focusedId = $state<string | null>(null);
|
|
||||||
|
|
||||||
function handleNodeKey(event: KeyboardEvent, id: string) {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
onSelect(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
|
// Canvas-level keyboard: `+`/`-` zoom by the fixed step (OQ-002), arrows pan by
|
||||||
// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
|
// a tenth of the visible extent. Nodes keep their own Enter/Space selection.
|
||||||
function handleCanvasKey(event: KeyboardEvent) {
|
function handleCanvasKey(event: KeyboardEvent) {
|
||||||
@@ -249,75 +242,6 @@ function handleCanvasKey(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
|
||||||
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
|
||||||
|
|
||||||
function pairKey(a: string, b: string): string {
|
|
||||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParentLinks = {
|
|
||||||
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
|
|
||||||
// + per-child vertical pattern in the SVG.
|
|
||||||
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
|
|
||||||
// One entry per remaining parent → child edge (single parents, or the
|
|
||||||
// "second" parent edge when only one parent is in the spouse pair).
|
|
||||||
single: { key: string; parentId: string; childId: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const parentLinks = $derived.by<ParentLinks>(() => {
|
|
||||||
const spousePairs = new SvelteSet<string>();
|
|
||||||
for (const e of spouseEdges) {
|
|
||||||
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const childToParents = new SvelteMap<string, string[]>();
|
|
||||||
for (const e of parentEdges) {
|
|
||||||
const list = childToParents.get(e.relatedPersonId) ?? [];
|
|
||||||
list.push(e.personId);
|
|
||||||
childToParents.set(e.relatedPersonId, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedMap = new SvelteMap<
|
|
||||||
string,
|
|
||||||
{ parentA: string; parentB: string; childIds: string[] }
|
|
||||||
>();
|
|
||||||
const single: ParentLinks['single'] = [];
|
|
||||||
for (const [childId, parents] of childToParents) {
|
|
||||||
const consumed = new SvelteSet<string>();
|
|
||||||
for (let i = 0; i < parents.length; i++) {
|
|
||||||
if (consumed.has(parents[i])) continue;
|
|
||||||
for (let j = i + 1; j < parents.length; j++) {
|
|
||||||
if (consumed.has(parents[j])) continue;
|
|
||||||
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
|
|
||||||
const groupKey = pairKey(parents[i], parents[j]);
|
|
||||||
const existing = sharedMap.get(groupKey);
|
|
||||||
if (existing) {
|
|
||||||
existing.childIds.push(childId);
|
|
||||||
} else {
|
|
||||||
sharedMap.set(groupKey, {
|
|
||||||
parentA: parents[i],
|
|
||||||
parentB: parents[j],
|
|
||||||
childIds: [childId]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
consumed.add(parents[i]);
|
|
||||||
consumed.add(parents[j]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const parentId of parents) {
|
|
||||||
if (consumed.has(parentId)) continue;
|
|
||||||
single.push({ key: `${parentId}->${childId}`, parentId, childId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shared: ParentLinks['shared'] = [];
|
|
||||||
for (const [key, group] of sharedMap) shared.push({ key, ...group });
|
|
||||||
return { shared, single };
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
|
<!-- Relative wrapper so the pinned generation rail can overlay the canvas. -->
|
||||||
@@ -364,180 +288,18 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
<StammbaumConnectors edges={edges} positions={layout.positions} />
|
||||||
bar, then short verticals from the bar to each child top. -->
|
|
||||||
{#each parentLinks.shared as group (group.key)}
|
|
||||||
{@const aCenter = nodeCenter(group.parentA)}
|
|
||||||
{@const bCenter = nodeCenter(group.parentB)}
|
|
||||||
{@const childCenters = group.childIds
|
|
||||||
.map((id) => nodeCenter(id))
|
|
||||||
.filter((c): c is { x: number; y: number } => c !== null)}
|
|
||||||
{#if aCenter && bCenter && childCenters.length > 0}
|
|
||||||
{@const midX = (aCenter.x + bCenter.x) / 2}
|
|
||||||
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
|
||||||
{@const childTopY = childCenters[0].y - NODE_H / 2}
|
|
||||||
{@const barY = (parentBottomY + childTopY) / 2}
|
|
||||||
{@const xs = childCenters.map((c) => c.x)}
|
|
||||||
{@const minX = Math.min(midX, ...xs)}
|
|
||||||
{@const maxX = Math.max(midX, ...xs)}
|
|
||||||
<line
|
|
||||||
x1={midX}
|
|
||||||
y1={parentBottomY}
|
|
||||||
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
|
|
||||||
x1={cc.x}
|
|
||||||
y1={barY}
|
|
||||||
x2={cc.x}
|
|
||||||
y2={childTopY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
|
||||||
{#each parentLinks.single as link (link.key)}
|
|
||||||
{@const parentCenter = nodeCenter(link.parentId)}
|
|
||||||
{@const childCenter = nodeCenter(link.childId)}
|
|
||||||
{#if parentCenter && childCenter}
|
|
||||||
{@const parentBottomY = parentCenter.y + NODE_H / 2}
|
|
||||||
{@const childTopY = childCenter.y - NODE_H / 2}
|
|
||||||
{@const barY = (parentBottomY + childTopY) / 2}
|
|
||||||
<line
|
|
||||||
x1={parentCenter.x}
|
|
||||||
y1={parentBottomY}
|
|
||||||
x2={parentCenter.x}
|
|
||||||
y2={barY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{#if parentCenter.x !== childCenter.x}
|
|
||||||
<line
|
|
||||||
x1={parentCenter.x}
|
|
||||||
y1={barY}
|
|
||||||
x2={childCenter.x}
|
|
||||||
y2={barY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<line
|
|
||||||
x1={childCenter.x}
|
|
||||||
y1={barY}
|
|
||||||
x2={childCenter.x}
|
|
||||||
y2={childTopY}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Spouse connectors -->
|
|
||||||
{#each spouseEdges as e (e.id)}
|
|
||||||
{@const aCenter = nodeCenter(e.personId)}
|
|
||||||
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
|
||||||
{#if aCenter && bCenter}
|
|
||||||
<line
|
|
||||||
x1={aCenter.x}
|
|
||||||
y1={aCenter.y}
|
|
||||||
x2={bCenter.x}
|
|
||||||
y2={bCenter.y}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={(aCenter.x + bCenter.x) / 2}
|
|
||||||
cy={(aCenter.y + bCenter.y) / 2}
|
|
||||||
r="6"
|
|
||||||
fill="var(--c-primary)"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Nodes -->
|
<!-- Nodes -->
|
||||||
{#each nodes as node (node.id)}
|
{#each nodes as node (node.id)}
|
||||||
{@const pos = layout.positions.get(node.id)}
|
{@const pos = layout.positions.get(node.id)}
|
||||||
{#if pos}
|
{#if pos}
|
||||||
{@const isSelected = selectedId === node.id}
|
<StammbaumNode
|
||||||
{@const isFocused = focusedId === node.id}
|
node={node}
|
||||||
<g
|
pos={pos}
|
||||||
role="button"
|
selected={selectedId === node.id}
|
||||||
tabindex="0"
|
onSelect={onSelect}
|
||||||
aria-label="{node.displayName}{node.birthYear || node.deathYear
|
/>
|
||||||
? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
|
|
||||||
: ''}"
|
|
||||||
aria-expanded={isSelected}
|
|
||||||
transform="translate({pos.x}, {pos.y})"
|
|
||||||
onclick={() => onSelect(node.id)}
|
|
||||||
onkeydown={(e) => handleNodeKey(e, node.id)}
|
|
||||||
onfocus={() => (focusedId = node.id)}
|
|
||||||
onblur={() => (focusedId = null)}
|
|
||||||
class="cursor-pointer focus:outline-none"
|
|
||||||
>
|
|
||||||
{#if isFocused}
|
|
||||||
<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}
|
|
||||||
<rect
|
|
||||||
width={NODE_W}
|
|
||||||
height={NODE_H}
|
|
||||||
rx="4"
|
|
||||||
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
|
||||||
stroke="var(--c-primary)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
{#if isSelected}
|
|
||||||
<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={isSelected ? '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={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
|
||||||
opacity={isSelected ? 0.75 : 1}
|
|
||||||
>
|
|
||||||
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
|
||||||
</text>
|
|
||||||
{/if}
|
|
||||||
</g>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user