Public tree view: add generation depth controls (shared with member view) #51
@@ -12,6 +12,7 @@ import type { components } from "@/lib/api/schema";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { FanChart } from "@/components/fan-chart";
|
import { FanChart } from "@/components/fan-chart";
|
||||||
|
import { DepthControl } from "@/components/depth-control";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
type Relationship = components["schemas"]["RelationshipRead"];
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
@@ -280,67 +281,6 @@ 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
// Vendored family-chart styles (the package blocks the CSS subpath export).
|
// Vendored family-chart styles (the package blocks the CSS subpath export).
|
||||||
import "../app/trees/[id]/tree/chart.css";
|
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 type { components } from "@/lib/api/schema";
|
||||||
|
import { DepthControl } from "@/components/depth-control";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
type Relationship = components["schemas"]["RelationshipRead"];
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
@@ -42,6 +43,16 @@ export function PublicTreeChart({
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const chartRef = useRef<any>(null);
|
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(
|
const parentsOf = useCallback(
|
||||||
(id: string) =>
|
(id: string) =>
|
||||||
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
|
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);
|
const chart = f3.createChart(containerRef.current, data);
|
||||||
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||||
chart.setOrientationHorizontal();
|
chart.setOrientationHorizontal();
|
||||||
chart.setAncestryDepth?.(3);
|
chart.setAncestryDepth?.(effAnc);
|
||||||
chart.setProgenyDepth?.(2);
|
chart.setProgenyDepth?.(effProg);
|
||||||
chart.setAfterUpdate?.(() => {
|
chart.setAfterUpdate?.(() => {
|
||||||
const md = chart.getMainDatum?.();
|
const md = chart.getMainDatum?.();
|
||||||
const id = md?.id ?? md?.data?.id;
|
const id = md?.id ?? md?.data?.id;
|
||||||
@@ -155,8 +166,41 @@ export function PublicTreeChart({
|
|||||||
}
|
}
|
||||||
}, [focusId]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-2">
|
<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
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="f3 rounded-xl border border-[var(--border)]"
|
className="f3 rounded-xl border border-[var(--border)]"
|
||||||
|
|||||||
Reference in New Issue
Block a user