Tree layout toggles (landscape/portrait/fan), card->profile, server search
Tree page gets Landscape/Portrait/Fan toggles: landscape & portrait via family-chart's orientation; a hand-rolled radial Fan chart of ancestors (rings per generation, click to recenter). Clicking a card recenters and updates an 'Open <name> →' link to that person's profile. The People directory search now hits the server-side pg_trgm fuzzy endpoint (debounced) so it spans the whole tree, not just the loaded page. 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:
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
// Radial fan chart of a focus person's ancestors (family-chart has no fan).
|
||||
// Each generation is a ring; slot p in generation g descends from slot floor(p/2)
|
||||
// in g-1. Click a wedge to refocus.
|
||||
|
||||
type Props = {
|
||||
focusId: string;
|
||||
parentsOf: (id: string) => string[];
|
||||
nameOf: (id: string) => string;
|
||||
yearOf: (id: string) => string;
|
||||
onSelect: (id: string) => void;
|
||||
generations?: number;
|
||||
};
|
||||
|
||||
const SIZE = 720;
|
||||
const CENTER = SIZE / 2;
|
||||
const FOCUS_R = 46;
|
||||
const SPAN = Math.PI * 1.6; // 288° fan
|
||||
|
||||
function polar(r: number, a: number): [number, number] {
|
||||
// a = 0 points up, increasing clockwise.
|
||||
return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)];
|
||||
}
|
||||
|
||||
function sector(r0: number, r1: number, a0: number, a1: number): string {
|
||||
const [x0, y0] = polar(r1, a0);
|
||||
const [x1, y1] = polar(r1, a1);
|
||||
const [x2, y2] = polar(r0, a1);
|
||||
const [x3, y3] = polar(r0, a0);
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`;
|
||||
}
|
||||
|
||||
function clip(s: string, n: number): string {
|
||||
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||
}
|
||||
|
||||
export function FanChart({
|
||||
focusId,
|
||||
parentsOf,
|
||||
nameOf,
|
||||
yearOf,
|
||||
onSelect,
|
||||
generations = 4,
|
||||
}: Props) {
|
||||
const gens: (string | null)[][] = [[focusId]];
|
||||
for (let g = 1; g <= generations; g++) {
|
||||
const row: (string | null)[] = [];
|
||||
for (const slot of gens[g - 1]) {
|
||||
const ps = slot ? parentsOf(slot) : [];
|
||||
row.push(ps[0] ?? null, ps[1] ?? null);
|
||||
}
|
||||
gens.push(row);
|
||||
}
|
||||
|
||||
const ringT = (CENTER - 60 - FOCUS_R) / generations;
|
||||
const start = -SPAN / 2;
|
||||
const wedges: React.ReactNode[] = [];
|
||||
|
||||
for (let g = 1; g <= generations; g++) {
|
||||
const row = gens[g];
|
||||
const w = SPAN / row.length;
|
||||
const r0 = FOCUS_R + (g - 1) * ringT;
|
||||
const r1 = FOCUS_R + g * ringT;
|
||||
row.forEach((id, i) => {
|
||||
const a0 = start + i * w;
|
||||
const a1 = start + (i + 1) * w;
|
||||
const mid = (a0 + a1) / 2;
|
||||
const [tx, ty] = polar((r0 + r1) / 2, mid);
|
||||
let deg = (mid * 180) / Math.PI;
|
||||
if (deg > 90 || deg < -90) deg += 180; // keep text upright
|
||||
wedges.push(
|
||||
<g
|
||||
key={`${g}-${i}`}
|
||||
onClick={() => id && onSelect(id)}
|
||||
style={{ cursor: id ? "pointer" : "default" }}
|
||||
>
|
||||
<path
|
||||
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
||||
fill={id ? "var(--surface)" : "transparent"}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
{id && (
|
||||
<text
|
||||
x={tx}
|
||||
y={ty}
|
||||
transform={`rotate(${deg} ${tx} ${ty})`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{ fontSize: g >= 3 ? 9 : 11, fill: "var(--foreground)" }}
|
||||
>
|
||||
{clip(nameOf(id), g >= 3 ? 12 : 18)}
|
||||
</text>
|
||||
)}
|
||||
</g>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const [fx, fy] = [CENTER, CENTER];
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} className="mx-auto block w-full max-w-3xl">
|
||||
{wedges}
|
||||
<circle cx={fx} cy={fy} r={FOCUS_R} fill="var(--color-bronze)" />
|
||||
<text
|
||||
x={fx}
|
||||
y={fy - 4}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{ fontSize: 12, fill: "var(--color-paper)", fontWeight: 600 }}
|
||||
>
|
||||
{clip(nameOf(focusId), 12)}
|
||||
</text>
|
||||
<text
|
||||
x={fx}
|
||||
y={fy + 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{ fontSize: 10, fill: "var(--color-paper)" }}
|
||||
>
|
||||
{yearOf(focusId)}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user