Pedigree connector lines + 4 grandparents #11
@@ -54,3 +54,55 @@ h3,
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
|
||||
own half of the vertical spine + a horizontal stub, so lines stay correct
|
||||
regardless of box heights: focus → 2 parents, each parent → 2 grandparents. */
|
||||
.ped-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ped-self {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ped-branch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
.ped-branch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2.5rem;
|
||||
top: 50%;
|
||||
width: 2.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.ped-leaf {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ped-leaf::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.ped-leaf::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.ped-leaf:first-child::after {
|
||||
top: 50%;
|
||||
}
|
||||
.ped-leaf:last-child::after {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export default function FamilyViewPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [firstName, setFirstName] = useState("");
|
||||
// Inline add-relative form: which anchor + kind is open, and the typed name.
|
||||
const [adding, setAdding] = useState<{ kind: AddKind; anchor: string } | null>(null);
|
||||
// `key` keeps each empty slot's inline form independent (a person has 2
|
||||
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
|
||||
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
|
||||
const [addName, setAddName] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -179,8 +181,18 @@ export default function FamilyViewPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const AddSlot = ({ kind, anchor, label }: { kind: AddKind; anchor: string; label: string }) =>
|
||||
adding && adding.kind === kind && adding.anchor === anchor ? (
|
||||
const AddSlot = ({
|
||||
formKey,
|
||||
kind,
|
||||
anchor,
|
||||
label,
|
||||
}: {
|
||||
formKey: string;
|
||||
kind: AddKind;
|
||||
anchor: string;
|
||||
label: string;
|
||||
}) =>
|
||||
adding?.key === formKey ? (
|
||||
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -205,7 +217,7 @@ export default function FamilyViewPage() {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setAdding({ kind, anchor });
|
||||
setAdding({ key: formKey, kind, anchor });
|
||||
setAddName("");
|
||||
}}
|
||||
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
|
||||
@@ -214,7 +226,39 @@ export default function FamilyViewPage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
const parents = parentsOf(focus.id);
|
||||
// Recursive ancestor chart (grows rightward): a node is its box plus a
|
||||
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
|
||||
// focus, capped at grandparents (depth 2).
|
||||
const renderNode = (
|
||||
slotPersonId: string | null,
|
||||
childId: string,
|
||||
keyPrefix: string,
|
||||
depth: number,
|
||||
): React.ReactNode => {
|
||||
const box = slotPersonId ? (
|
||||
<PersonBox id={slotPersonId} muted={depth > 0} />
|
||||
) : (
|
||||
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
|
||||
);
|
||||
if (!slotPersonId || depth >= 2) {
|
||||
return <div className="ped-person">{box}</div>;
|
||||
}
|
||||
const ps = parentsOf(slotPersonId);
|
||||
return (
|
||||
<div className="ped-person">
|
||||
<div className="ped-self">{box}</div>
|
||||
<div className="ped-branch">
|
||||
<div className="ped-leaf">
|
||||
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
|
||||
</div>
|
||||
<div className="ped-leaf">
|
||||
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const partners = partnersOf(focus.id);
|
||||
const children = childrenOf(focus.id);
|
||||
|
||||
@@ -237,46 +281,10 @@ export default function FamilyViewPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pedigree: focus → parents → grandparents */}
|
||||
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||
<Card>
|
||||
<CardContent className="overflow-x-auto p-6">
|
||||
<div className="flex min-w-[40rem] items-stretch gap-8">
|
||||
<div className="flex flex-1 flex-col justify-center gap-3">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Focus
|
||||
</div>
|
||||
<PersonBox id={focus.id} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-4">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Parents
|
||||
</div>
|
||||
{parents.map((pid) => (
|
||||
<PersonBox key={pid} id={pid} muted />
|
||||
))}
|
||||
{parents.length < 2 && <AddSlot kind="parent" anchor={focus.id} label="add parent" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-4">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Grandparents
|
||||
</div>
|
||||
{parents.length === 0 && (
|
||||
<div className="text-sm text-[var(--muted)]">Add parents first.</div>
|
||||
)}
|
||||
{parents.map((pid) => (
|
||||
<div key={pid} className="flex flex-col gap-2">
|
||||
{parentsOf(pid).map((gp) => (
|
||||
<PersonBox key={gp} id={gp} muted />
|
||||
))}
|
||||
{parentsOf(pid).length < 2 && (
|
||||
<AddSlot kind="parent" anchor={pid} label="add parent" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -289,7 +297,12 @@ export default function FamilyViewPage() {
|
||||
{partners.map((id) => (
|
||||
<PersonBox key={id} id={id} muted />
|
||||
))}
|
||||
<AddSlot kind="partner" anchor={focus.id} label="add spouse" />
|
||||
<AddSlot
|
||||
formKey={`partner-${focus.id}`}
|
||||
kind="partner"
|
||||
anchor={focus.id}
|
||||
label="add spouse"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -301,7 +314,12 @@ export default function FamilyViewPage() {
|
||||
{children.map((id) => (
|
||||
<PersonBox key={id} id={id} muted />
|
||||
))}
|
||||
<AddSlot kind="child" anchor={focus.id} label="add child" />
|
||||
<AddSlot
|
||||
formKey={`child-${focus.id}`}
|
||||
kind="child"
|
||||
anchor={focus.id}
|
||||
label="add child"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user