Family tree
Loading…
Source code
$ npx @flitterjs/cli@latest add flow-diagramimport {
Alignment,
Border,
BorderRadius,
BoxDecoration,
Column,
Container,
CrossAxisAlignment,
EdgeInsets,
Expanded,
GestureDetector,
MainAxisSize,
Row,
SizedBox,
State,
StatefulWidget,
Text,
TextStyle,
type Widget,
} from "flitterjs";
import {
FlowDiagramController,
createId,
type FlowEdge,
type FlowNode,
} from "@flitterjs/diagram";
import FlowDiagram from "@/components/diagram/flow-diagram";
type Theme = "light" | "dark";
class Family extends StatefulWidget {
constructor(readonly theme: Theme) {
super(theme);
}
createState() {
return new FamilyState();
}
}
class FamilyState extends State<Family> {
flow = new FlowDiagramController();
diagram?: Widget;
serial = 0;
nodes: FlowNode[] = [
["thomas", "Thomas", "1948", 240, 0, "#638c7c"],
["eva", "Eva", "1951", 420, 0, "#b87a64"],
["noah", "Noah", "1975", 60, 210, "#638c7c"],
["mia", "Mia", "1978", 240, 210, "#a582ba"],
["leo", "Leo", "1980", 500, 210, "#b8914e"],
["anna", "Anna", "1982", 680, 210, "#6c8faa"],
["isla", "Isla", "2003", 0, 420, "#b87a64"],
["owen", "Owen", "2006", 160, 420, "#638c7c"],
["ella", "Ella", "2008", 550, 420, "#a582ba"],
["liam", "Liam", "2011", 710, 420, "#b8914e"],
].map(([id, label, year, x, y, color]) => ({
id: String(id),
type: "person",
toolbar: { position: Number(y) === 0 ? "bottom" : "top", offset: 8 },
width: 104,
height: 124,
position: { x: Number(x), y: Number(y) },
data: { label, year, color },
}));
edges: FlowEdge[] = [];
constructor() {
super();
for (const [id, first, second, x, y, children] of [
["grandparents", "thomas", "eva", 381, 37, ["noah", "leo"]],
["noah-family", "noah", "mia", 201, 247, ["isla", "owen"]],
["leo-family", "leo", "anna", 641, 247, ["ella", "liam"]],
] as const) {
this.nodes.push({
id,
type: "union",
width: 2,
height: 2,
position: { x, y },
data: {},
selectable: false,
draggable: false,
});
this.edges.push(
{
id: `${id}-a`,
source: first,
sourceHandle: "right",
target: id,
targetHandle: "left",
type: "straight",
selectable: false,
},
{
id: `${id}-b`,
source: second,
sourceHandle: "left",
target: id,
targetHandle: "right",
type: "straight",
selectable: false,
},
...children.map((child) => ({
id: `${id}-${child}`,
source: id,
sourceHandle: "child",
target: child,
targetHandle: "parent",
type: "smoothstep",
selectable: false,
})),
);
}
}
get c() {
return this.widget.theme === "dark"
? {
bg: "#191d1a",
ink: "#f0eee6",
muted: "#b7b8ac",
branch: "#a39876",
panel: "#242b25",
}
: {
bg: "#fbf8ef",
ink: "#33473b",
muted: "#758172",
branch: "#b0a382",
panel: "#f0ecdf",
};
}
text(value: string, size = 16, color = this.c.ink) {
return Text(value, {
style: new TextStyle({ fontFamily: "system-ui", fontSize: size, color }),
});
}
button(label: string, action: () => void) {
return GestureDetector({
onMouseDown: (e) => e.stopPropagation(),
onClick: (e) => {
e.stopPropagation();
action();
},
child: Container({
padding: EdgeInsets.symmetric({ horizontal: 13, vertical: 10 }),
decoration: new BoxDecoration({
color: this.c.panel,
borderRadius: BorderRadius.circular(7),
}),
child: this.text(label, 14),
}),
});
}
familyOf(person: FlowNode) {
return this.flow.getOutgoers(person).find((node) => node.type === "union");
}
syncHiddenBranches() {
const hidden = new Set<string>();
for (const family of this.flow.nodes.filter(
(node) => node.type === "union" && node.data.folded,
)) {
const seen = new Set<string>();
const queue = this.flow.getOutgoers(family).map((node) => node.id);
for (let index = 0; index < queue.length; index++) {
const id = queue[index];
if (seen.has(id)) continue;
seen.add(id);
hidden.add(id);
const node = this.flow.getNode(id);
if (!node) continue;
// Partners in descendant households fold together; the selected couple stays visible.
if (node.type === "union")
this.flow
.getIncomers(node)
.forEach((parent) => hidden.add(parent.id));
queue.push(...this.flow.getOutgoers(node).map((child) => child.id));
}
}
this.flow.setNodes((nodes) =>
nodes.map((node) => ({ ...node, hidden: hidden.has(node.id) })),
);
}
toggleBranch(person: FlowNode) {
const family = this.familyOf(person);
if (!family) return;
this.flow.history.mark("Fold family branch");
this.flow.run(
() => {
this.flow.updateNodeData(family.id, { folded: !family.data.folded });
this.syncHiddenBranches();
},
{ history: "merge" },
);
}
addChild(parent: FlowNode) {
const existing = this.flow
.getOutgoers(parent)
.find((node) => node.type === "union");
const unionId = existing?.id ?? createId("family");
const parentOrigin = existing?.position ?? {
x: parent.position.x + 51,
y: parent.position.y + 150,
};
const siblings = existing ? this.flow.getOutgoers(existing) : [];
let x = siblings.length
? Math.max(...siblings.map((node) => node.position.x)) + 160
: parent.position.x;
const childY = parentOrigin.y + 173;
while (
this.flow.nodes.some(
(node) =>
node.type === "person" &&
Math.abs(node.position.y - childY) < 124 &&
Math.abs(node.position.x - x) < 120,
)
)
x += 160;
const id = createId("person");
const label = ["Alex", "Robin", "Sam", "Charlie"][this.serial % 4];
this.setState(() => {
this.serial++;
});
this.flow.history.mark("Add child");
this.flow.run(
() => {
if (!existing) {
this.flow.addNodes({
id: unionId,
type: "union",
position: parentOrigin,
width: 2,
height: 2,
data: {},
selectable: false,
draggable: false,
});
this.flow.addEdges({
id: createId("parent"),
source: parent.id,
sourceHandle: "child",
target: unionId,
targetHandle: "parent",
type: "straight",
selectable: false,
});
}
this.flow.updateNodeData(unionId, { folded: false });
this.flow.addNodes({
id,
type: "person",
position: { x, y: childY },
toolbar: { position: "top", offset: 8 },
width: 104,
height: 124,
data: { label, year: "", color: parent.data.color },
});
this.flow.addEdges({
id: createId("child"),
source: unionId,
sourceHandle: "child",
target: id,
targetHandle: "parent",
type: "smoothstep",
selectable: false,
});
this.syncHiddenBranches();
this.flow.selectNodes([id]);
},
{ history: "merge" },
);
void this.flow.fitView({ padding: 0.1, maxZoom: 1 });
}
createDiagram(): Widget {
const c = this.c;
return FlowDiagram({
controller: this.flow,
data: { nodes: this.nodes, edges: this.edges },
viewport: { minZoom: 0.15, fitView: { padding: 0.08, maxZoom: 1 } },
interaction: {
connect: { enabled: false },
keys: { delete: null },
contextMenu: { enabled: false },
},
config: {
colorMode: this.widget.theme,
colors: { background: c.bg, edgeStroke: c.branch },
edge: { strokeWidth: 2 },
minimap: { visible: false },
controls: { showInteractive: false },
},
nodeOptions: {
person: {
handles: [
{
id: "left",
type: "source",
position: "left",
width: 0,
height: 0,
x: 14,
y: 38,
},
{
id: "right",
type: "source",
position: "right",
width: 0,
height: 0,
x: 90,
y: 38,
},
{
id: "parent",
type: "target",
position: "top",
width: 0,
height: 0,
},
{
id: "child",
type: "source",
position: "bottom",
width: 0,
height: 0,
},
],
},
union: {
handles: [
{
id: "left",
type: "target",
position: "left",
x: 1,
y: 1,
width: 0,
height: 0,
},
{
id: "right",
type: "target",
position: "right",
x: 1,
y: 1,
width: 0,
height: 0,
},
{
id: "parent",
type: "target",
position: "top",
x: 1,
y: 1,
width: 0,
height: 0,
},
{
id: "child",
type: "source",
position: "bottom",
x: 1,
y: 1,
width: 0,
height: 0,
},
],
},
},
custom: {
background: () => Container({ color: c.bg }),
handle: () => SizedBox.shrink(),
node: ({ node, isSelected }) =>
node.type === "union"
? SizedBox.shrink()
: Column({
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container({
width: 76,
height: 76,
alignment: Alignment.center,
decoration: new BoxDecoration({
shape: "circle",
color: String(node.data.color),
border: Border.all({
color: isSelected ? c.ink : c.bg,
width: 3,
}),
}),
child: this.text(
String(node.data.label).slice(0, 1),
30,
"#ffffff",
),
}),
SizedBox({ height: 8 }),
this.text(
`${node.data.label}${this.familyOf(node)?.data.folded ? " +" : ""}`,
17,
),
],
}),
nodeToolbar: ({ node }) =>
node.type === "person"
? Row({
mainAxisSize: MainAxisSize.min,
children: [
this.button("+ Child", () => this.addChild(node)),
...(this.familyOf(node)
? [
SizedBox({ width: 4 }),
this.button(
this.familyOf(node)?.data.folded
? "Expand"
: "Collapse",
() => this.toggleBranch(node),
),
]
: []),
],
})
: SizedBox.shrink(),
controls: ({ zoomIn, zoomOut, fitView }) =>
Row({
mainAxisSize: MainAxisSize.min,
children: [zoomOut, zoomIn, fitView],
}),
},
});
}
build(): Widget {
return Column({
children: [
Container({
height: 52,
color: this.c.bg,
padding: EdgeInsets.symmetric({ horizontal: 16 }),
child: Row({
children: [
Expanded({ child: this.text("The Morgan family", 18) }),
this.button("Undo", () => this.flow.history.undo()),
],
}),
}),
Expanded({ child: (this.diagram ??= this.createDiagram()) }),
],
});
}
dispose() {
this.flow.dispose();
super.dispose();
}
}
export default function FamilyTree(theme: Theme = "light"): Widget {
return new Family(theme);
}People use circular monograms. Explicit union nodes keep both parents attached to the same sibling branch, including newly added children. All names are fictional.
Select a person to add a child or collapse descendants. Undo restores the family structure.
Demo data; execution and deployment are simulated.