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:
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user