The shared parent-pair child loop read group.childIds[i] while iterating
the filtered childCenters, so a child without a position would desync the
id from the centre — and that index now also drives the active-connector
lookup. Ride the id on the mapped {id,x,y} centre so the two never drift;
a positionless child drops out of both together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
7.5 KiB
Svelte
243 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
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';
|
|
|
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
|
|
interface Props {
|
|
edges: RelationshipDTO[];
|
|
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, 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 {
|
|
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) => {
|
|
const c = nodeCenter(id);
|
|
return c ? { id, x: c.x, y: c.y } : null;
|
|
})
|
|
.filter((c): c is { id: string; 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)}
|
|
{@const pairActive = isConnectorActive(group.parentA, group.parentB)}
|
|
<!-- Drop from the spouse midpoint to the sibling bar: joins the parent pair. -->
|
|
<g class="lineage-fade" opacity={connectorOpacity(pairActive)}>
|
|
<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}
|
|
</g>
|
|
{#each childCenters as cc (cc.id)}
|
|
<!-- 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. The child
|
|
id rides on the centre object, so it never desyncs from the filtered
|
|
centres (a child without a position drops out of both together). -->
|
|
{@const childActive =
|
|
isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)}
|
|
<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}
|
|
{/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}
|
|
{@const active = isConnectorActive(link.parentId, link.childId)}
|
|
<g class="lineage-fade" opacity={connectorOpacity(active)}>
|
|
<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"
|
|
/>
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- Spouse connectors -->
|
|
{#each spouseEdges as e (e.id)}
|
|
{@const aCenter = nodeCenter(e.personId)}
|
|
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
|
{#if aCenter && bCenter}
|
|
{@const active = isConnectorActive(e.personId, e.relatedPersonId)}
|
|
<g class="lineage-fade" opacity={connectorOpacity(active)}>
|
|
<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)"
|
|
/>
|
|
</g>
|
|
{/if}
|
|
{/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>
|