99913ada94
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>
129 lines
3.6 KiB
TypeScript
129 lines
3.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|