Tree view: configurable generation depth (ancestors/descendants + All)

Depth was hardcoded (3 ancestors, 2 descendants). Add a controls row to set
each direction independently — a slider plus a number stepper, with an "All"
toggle per direction — applied around whoever is currently focused.

- ancestor/descendant depth held in state; effective value is a large cap
  when "All" is on (the chart only renders people that exist, so the cap is
  free).
- changes apply to the live chart via setAncestryDepth/setProgenyDepth +
  updateTree without a full rebuild.
- fan mode (ancestors only) takes the ancestor depth via its `generations`
  prop, capped at 8 to avoid the radial layout's 2^n blow-up; its descendants
  control is disabled with a note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-08 22:20:07 -04:00
parent b51b65de80
commit c86771034c
+117 -3
View File
@@ -46,6 +46,16 @@ export default function TreePage() {
const [homeId, setHomeId] = useState<string | null>(null); const [homeId, setHomeId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape"); const [mode, setMode] = useState<Mode>("landscape");
const [renderNote, setRenderNote] = useState<string | null>(null); const [renderNote, setRenderNote] = useState<string | null>(null);
// How many generations to show around the focus, each independently settable
// (or "all"). ALL_DEPTH is just a number bigger than any real lineage; the
// chart only renders people that exist, so a high cap costs nothing.
const [ancDepth, setAncDepth] = useState(3); // ancestors (backwards)
const [progDepth, setProgDepth] = useState(2); // descendants (forwards)
const [ancAll, setAncAll] = useState(false);
const [progAll, setProgAll] = useState(false);
const ALL_DEPTH = 100;
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
const effProg = progAll ? ALL_DEPTH : progDepth;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -185,9 +195,9 @@ export default function TreePage() {
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]); chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
if (mode === "portrait") chart.setOrientationVertical(); if (mode === "portrait") chart.setOrientationVertical();
else chart.setOrientationHorizontal(); else chart.setOrientationHorizontal();
// Show enough generations that a recenter reveals grandparents + children. // Generations to show around the focus (configurable; see depth controls).
chart.setAncestryDepth?.(3); chart.setAncestryDepth?.(effAnc);
chart.setProgenyDepth?.(2); chart.setProgenyDepth?.(effProg);
// Default card click recenters the whole hourglass; sync focus for the // Default card click recenters the whole hourglass; sync focus for the
// "Open profile" link after every (re)build. // "Open profile" link after every (re)build.
chart.setAfterUpdate?.(() => { chart.setAfterUpdate?.(() => {
@@ -219,6 +229,15 @@ export default function TreePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, mode, people, rels, events]); }, [status, mode, people, rels, events]);
// Apply depth changes to the already-built chart without a full rebuild
// (landscape/portrait only; the fan reads its own `generations` prop).
useEffect(() => {
if (mode === "fan" || !chartRef.current) return;
chartRef.current.setAncestryDepth?.(effAnc);
chartRef.current.setProgenyDepth?.(effProg);
chartRef.current.updateTree?.();
}, [effAnc, effProg, mode]);
// Jump the tree (or fan) to a person and rebuild the hourglass around them. // Jump the tree (or fan) to a person and rebuild the hourglass around them.
const goTo = useCallback( const goTo = useCallback(
(id: string) => { (id: string) => {
@@ -261,6 +280,68 @@ export default function TreePage() {
</button> </button>
); );
// Slider + number stepper + "All" for one generation direction.
const DepthControl = ({
label,
icon,
value,
all,
onValue,
onAll,
disabled,
}: {
label: string;
icon: string;
value: number;
all: boolean;
onValue: (v: number) => void;
onAll: (b: boolean) => void;
disabled?: boolean;
}) => (
<div className={`flex items-center gap-2 ${disabled ? "opacity-40" : ""}`}>
<span className="flex w-24 items-center gap-1 text-xs text-[var(--muted)]">
<span aria-hidden>{icon}</span> {label}
</span>
<input
type="range"
min={0}
max={12}
step={1}
value={all ? 12 : value}
disabled={disabled || all}
onChange={(e) => onValue(Number(e.target.value))}
className="w-28 accent-bronze"
aria-label={`${label} generations`}
/>
{all ? (
<span className="w-10 text-center text-sm font-medium text-bronze">All</span>
) : (
<input
type="number"
min={0}
max={99}
value={value}
disabled={disabled}
onChange={(e) => onValue(Math.max(0, Math.min(99, Number(e.target.value) || 0)))}
className="h-7 w-12 rounded-md border border-[var(--border)] bg-[var(--surface)] px-1 text-center text-sm"
/>
)}
<button
type="button"
disabled={disabled}
onClick={() => onAll(!all)}
title={`Show all ${label.toLowerCase()}`}
className={`rounded-md px-2 py-1 text-xs transition-colors ${
all
? "bg-bronze text-paper"
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
All
</button>
</div>
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -319,6 +400,38 @@ export default function TreePage() {
</div> </div>
</div> </div>
{status === "ready" && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
<span className="text-sm font-medium">Generations</span>
<DepthControl
label="Ancestors"
icon="↑"
value={ancDepth}
all={ancAll}
onValue={(v) => {
setAncAll(false);
setAncDepth(v);
}}
onAll={setAncAll}
/>
<DepthControl
label="Descendants"
icon="↓"
value={progDepth}
all={progAll}
onValue={(v) => {
setProgAll(false);
setProgDepth(v);
}}
onAll={setProgAll}
disabled={mode === "fan"}
/>
{mode === "fan" && (
<span className="text-xs text-[var(--muted)]">Fan shows ancestors only.</span>
)}
</div>
)}
{status === "empty" && ( {status === "empty" && (
<p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p> <p className="text-[var(--muted)]">No people yet add some under People, or import a GEDCOM.</p>
)} )}
@@ -338,6 +451,7 @@ export default function TreePage() {
nameOf={nameOf} nameOf={nameOf}
yearOf={yearOf} yearOf={yearOf}
onSelect={setFocusId} onSelect={setFocusId}
generations={Math.min(effAnc, 8)}
/> />
</div> </div>
) : ( ) : (