Gallery

Genetic pedigree

Loading…
Source code
$ npx @flitterjs/cli@latest add flow-diagram
import {
  BorderRadius,
  BoxDecoration,
  Column,
  Container,
  CrossAxisAlignment,
  EdgeInsets,
  Expanded,
  GestureDetector,
  LayoutBuilder,
  MainAxisSize,
  Row,
  SizedBox,
  State,
  StatefulWidget,
  Text,
  TextStyle,
  type Widget,
} from "flitterjs";
import {
  FlowDiagramController,
  type FlowEdge,
  type FlowNode,
} from "@flitterjs/diagram";
import FlowDiagram, { ShapePaint } from "@/components/diagram/flow-diagram";

type Theme = "light" | "dark";
type Mode = "select" | "trait" | "deceased";
class Pedigree extends StatefulWidget {
  constructor(readonly theme: Theme) {
    super(theme);
  }
  createState() {
    return new PedigreeState();
  }
}
class PedigreeState extends State<Pedigree> {
  flow = new FlowDiagramController();
  diagram?: Widget;
  mode: Mode = "trait";
  nodes: FlowNode[] = [
    ["I-1", "male", 240, 0, false],
    ["I-2", "female", 400, 0, true],
    ["II-1", "male", 60, 190, true],
    ["II-2", "female", 240, 190, false],
    ["II-3", "male", 440, 190, false],
    ["II-4", "female", 600, 190, true],
    ["III-1", "female", 370, 380, false],
    ["III-2", "male", 530, 380, true],
    ["III-3", "unknown", 690, 380, false],
  ].map(([id, symbol, x, y, affected]) => ({
    id: String(id),
    type: "person",
    position: { x: Number(x), y: Number(y) },
    width: 64,
    height: 88,
    draggable: false,
    data: { label: id, symbol, affected, deceased: false },
  }));
  edges: FlowEdge[] = [];
  constructor() {
    super();
    for (const [id, first, second, x, y, children] of [
      ["union-I", "I-1", "I-2", 351, 25, ["II-1", "II-2", "II-3"]],
      ["union-II", "II-3", "II-4", 551, 215, ["III-1", "III-2", "III-3"]],
    ] as const) {
      this.nodes.push({
        id,
        type: "union",
        position: { x, y },
        width: 2,
        height: 2,
        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: "step",
          selectable: false,
        })),
      );
    }
  }
  get c() {
    return this.widget.theme === "dark"
      ? {
          bg: "#131a24",
          ink: "#e5eef8",
          line: "#a2b3c9",
          accent: "#73aaff",
          panel: "#25354b",
        }
      : {
          bg: "#fcfdff",
          ink: "#26364c",
          line: "#5b6b82",
          accent: "#2563eb",
          panel: "#e7effc",
        };
  }
  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, active = false) {
    return GestureDetector({
      onMouseDown: (e) => e.stopPropagation(),
      onClick: (e) => {
        e.stopPropagation();
        action();
      },
      child: Container({
        padding: EdgeInsets.symmetric({ horizontal: 12, vertical: 10 }),
        decoration: new BoxDecoration({
          color: active ? this.c.panel : this.c.bg,
          borderRadius: BorderRadius.circular(6),
        }),
        child: this.text(label, 14, active ? this.c.accent : this.c.ink),
      }),
    });
  }
  mark(node: FlowNode) {
    if (node.type !== "person" || this.mode === "select") return;
    const field = this.mode === "trait" ? "affected" : "deceased";
    this.flow.history.mark(`Toggle ${field}`);
    this.flow.run(
      () => this.flow.updateNodeData(node.id, { [field]: !node.data[field] }),
      { history: "merge" },
    );
  }
  createDiagram(): Widget {
    const c = this.c;
    return FlowDiagram({
      controller: this.flow,
      data: { nodes: this.nodes, edges: this.edges },
      interaction: {
        preset: "viewer",
        history: { enabled: true },
        keys: { undo: "Mod+z", redo: "Mod+Shift+z" },
      },
      viewport: { minZoom: 0.2, fitView: { padding: 0.1, maxZoom: 1.15 } },
      on: { nodeClick: (_event, node) => this.mark(node) },
      config: {
        colorMode: this.widget.theme,
        colors: { background: c.bg, edgeStroke: c.line },
        edge: { strokeWidth: 2 },
        minimap: { visible: false },
        controls: { showInteractive: false },
      },
      nodeOptions: {
        person: {
          handles: [
            {
              id: "left",
              type: "source",
              position: "left",
              width: 0,
              height: 0,
              x: 10,
              y: 26,
            },
            {
              id: "right",
              type: "source",
              position: "right",
              width: 0,
              height: 0,
              x: 54,
              y: 26,
            },
            {
              id: "parent",
              type: "target",
              position: "top",
              width: 0,
              height: 0,
              x: 32,
              y: 4,
            },
          ],
        },
        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: "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: [
                  ShapePaint({
                    width: 64,
                    height: 60,
                    shapes: {
                      symbol: {
                        d:
                          node.data.symbol === "male"
                            ? "M10 4H54V48H10Z"
                            : node.data.symbol === "female"
                              ? "M10 26A22 22 0 1 0 54 26A22 22 0 1 0 10 26"
                              : "M32 2L58 26L32 50L6 26Z",
                        fill: node.data.affected ? c.ink : c.bg,
                        stroke: isSelected ? c.accent : c.ink,
                        strokeWidth: isSelected ? 3 : 2,
                      },
                      deceased: node.data.deceased
                        ? { d: "M3 56L61 0", stroke: c.ink, strokeWidth: 3 }
                        : null,
                    },
                  }),
                  SizedBox({ height: 4 }),
                  this.text(node.id, 16),
                ],
              }),
        controls: ({ zoomIn, zoomOut, fitView }) =>
          Row({
            mainAxisSize: MainAxisSize.min,
            children: [zoomOut, zoomIn, fitView],
          }),
      },
    });
  }
  build(): Widget {
    return LayoutBuilder({
      builder: (_context, size) =>
        Column({
          children: [
            Container({
              height: 54,
              color: this.c.bg,
              padding: EdgeInsets.symmetric({ horizontal: 12 }),
              child: Row({
                children: [
                  ...(size.maxWidth > 600
                    ? [Expanded({ child: this.text("Pedigree", 18) })]
                    : []),
                  this.button(
                    "Select",
                    () =>
                      this.setState(() => {
                        this.mode = "select";
                      }),
                    this.mode === "select",
                  ),
                  this.button(
                    "Trait",
                    () =>
                      this.setState(() => {
                        this.mode = "trait";
                      }),
                    this.mode === "trait",
                  ),
                  this.button(
                    "Deceased",
                    () =>
                      this.setState(() => {
                        this.mode = "deceased";
                      }),
                    this.mode === "deceased",
                  ),
                  this.button("Undo", () => this.flow.history.undo()),
                ],
              }),
            }),
            Expanded({ child: (this.diagram ??= this.createDiagram()) }),
          ],
        }),
    });
  }
  dispose() {
    this.flow.dispose();
    super.dispose();
  }
}
export default function GeneticPedigree(theme: Theme = "light"): Widget {
  return new Pedigree(theme);
}

Square, circle, and diamond symbols replace cards. Partner and sibling lines encode family structure. The fictional trait is recorded visually; this example does not calculate inheritance or assess medical risk.

In Trait mode, click a person to toggle a filled symbol. In Deceased mode, click to toggle the diagonal mark. Undo reverses either edit.

Demo data; execution and deployment are simulated.

Related