Public tree view: add generation depth controls (shared with member view)

The public tree chart was fixed at 3 ancestors / 2 descendants. Add the same
Generations controls the member view has (slider + number stepper + "All" per
direction), applied live around the focused person.

Extracts the member page's inline DepthControl into a shared
components/depth-control.tsx and uses it in both, so they stay in sync. The
public chart gains anc/prog depth state + an apply effect (setAncestryDepth/
setProgenyDepth + updateTree) mirroring the member behavior.

tsc clean; next build passes.

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-09 10:35:43 -04:00
parent 12ba0a0fb6
commit 7d6fbce87e
3 changed files with 114 additions and 64 deletions
+1 -61
View File
@@ -12,6 +12,7 @@ import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FanChart } from "@/components/fan-chart";
import { DepthControl } from "@/components/depth-control";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
@@ -280,67 +281,6 @@ export default function TreePage() {
</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 (
<div className="space-y-4">
+66
View File
@@ -0,0 +1,66 @@
"use client";
// Slider + number stepper + "All" toggle for one generation direction
// (ancestors or descendants). Shared by the member and public tree views.
export function 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;
}) {
return (
<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>
);
}
+47 -3
View File
@@ -3,9 +3,10 @@
// Vendored family-chart styles (the package blocks the CSS subpath export).
import "../app/trees/[id]/tree/chart.css";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema";
import { DepthControl } from "@/components/depth-control";
type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
@@ -42,6 +43,16 @@ export function PublicTreeChart({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
// Generations to show around the focus, each settable (or "all"). ALL_DEPTH is
// a number bigger than any real lineage; the chart only renders real people.
const [ancDepth, setAncDepth] = useState(3);
const [progDepth, setProgDepth] = useState(2);
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;
const parentsOf = useCallback(
(id: string) =>
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
@@ -126,8 +137,8 @@ export function PublicTreeChart({
const chart = f3.createChart(containerRef.current, data);
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
chart.setOrientationHorizontal();
chart.setAncestryDepth?.(3);
chart.setProgenyDepth?.(2);
chart.setAncestryDepth?.(effAnc);
chart.setProgenyDepth?.(effProg);
chart.setAfterUpdate?.(() => {
const md = chart.getMainDatum?.();
const id = md?.id ?? md?.data?.id;
@@ -155,8 +166,41 @@ export function PublicTreeChart({
}
}, [focusId]);
// Apply depth changes to the live chart without a full rebuild.
useEffect(() => {
if (!chartRef.current) return;
chartRef.current.setAncestryDepth?.(effAnc);
chartRef.current.setProgenyDepth?.(effProg);
chartRef.current.updateTree?.();
}, [effAnc, effProg]);
return (
<div className="space-y-2">
<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}
/>
</div>
<div
ref={containerRef}
className="f3 rounded-xl border border-[var(--border)]"