Skip to main content

lib/genogram_maker.ex

defmodule GenogramMaker do
  @moduledoc """
  Build family genograms as plain Elixir data, then render them to
  [Graphviz DOT](https://graphviz.org) or [Mermaid](https://mermaid.js.org).

  A genogram is a family diagram: people (with sex and vital status), the unions
  between them (marriage, divorce, cohabitation, …) with their children, and the
  emotional bonds that connect individuals (close, conflict, cutoff, …). Describe
  one with a small pipeline-friendly API and turn it into diagram source you can
  render with Graphviz or drop straight into Markdown.

      GenogramMaker.new()
      |> GenogramMaker.add_person("ego", :female, name: "Ada", proband: true)
      |> GenogramMaker.add_person("dad", :male, name: "John", birth: 1948, death: 2009)
      |> GenogramMaker.add_person("mom", :female, name: "Mary", birth: 1950)
      |> GenogramMaker.add_union("dad", "mom", type: :marriage, children: ["ego"])
      |> GenogramMaker.add_bond("dad", "ego", :conflict)
      |> GenogramMaker.to_dot()

  Standard genogram conventions are applied on render: males are squares, females
  circles and unknown-sex people diamonds; deceased people are shaded and the
  proband gets a double outline. Partners and their children are linked through a
  union point, and emotional bonds are drawn as non-structural coloured lines so
  they never distort the generational layout.

  People referenced by a union or bond are created automatically (with unknown
  sex) if they have not been added yet, so the pipeline above stays compact.

  Maintained by the team behind [AutoGenogram](https://autogenogram.com), an AI
  genogram maker and analyzer; this library is the code-first companion for
  generating genogram source programmatically.
  """

  alias GenogramMaker.{Bond, DOT, Mermaid, Person, Union}

  @sexes [:male, :female, :unknown]
  @union_types [:marriage, :divorce, :separation, :cohabitation, :engagement]
  @bond_types [:close, :distant, :conflict, :hostile, :cutoff, :fused]

  @typedoc "An id for a person; atoms and other terms are coerced to strings."
  @type id :: String.t() | atom()

  @type t :: %__MODULE__{
          people: [Person.t()],
          unions: [Union.t()],
          bonds: [Bond.t()],
          title: String.t() | nil
        }

  defstruct people: [], unions: [], bonds: [], title: nil

  @doc """
  Create an empty genogram.

  Options:

    * `:title` — optional diagram title (rendered as a graph label in DOT output).
  """
  @spec new(keyword()) :: t()
  def new(opts \\ []) do
    %__MODULE__{title: Keyword.get(opts, :title)}
  end

  @doc """
  Add a person, or update them if the `id` already exists.

  `sex` must be one of `:male`, `:female`, `:unknown`.

  Options:

    * `:name` — display name (defaults to the id on render).
    * `:birth` / `:death` — years shown under the name.
    * `:deceased` — defaults to `true` when `:death` is given, otherwise `false`.
    * `:proband` — mark the index person (double outline). Defaults to `false`.
    * `:note` — free-form note stored on the struct.
  """
  @spec add_person(t(), id(), Person.sex(), keyword()) :: t()
  def add_person(genogram, id, sex, opts \\ [])

  def add_person(%__MODULE__{} = genogram, id, sex, opts) when sex in @sexes do
    death = Keyword.get(opts, :death)

    person = %Person{
      id: to_string(id),
      name: Keyword.get(opts, :name),
      sex: sex,
      birth: Keyword.get(opts, :birth),
      death: death,
      deceased: Keyword.get(opts, :deceased, not is_nil(death)),
      proband: Keyword.get(opts, :proband, false),
      note: Keyword.get(opts, :note)
    }

    %{genogram | people: put_person(genogram.people, person)}
  end

  def add_person(%__MODULE__{}, _id, sex, _opts) do
    raise ArgumentError, "unknown sex #{inspect(sex)}, expected one of #{inspect(@sexes)}"
  end

  @doc """
  Add a union between two partners.

  Options:

    * `:type` — one of `:marriage` (default), `:divorce`, `:separation`,
      `:cohabitation`, `:engagement`.
    * `:children` — list of child ids descending from the union.

  Partners and children that do not exist yet are created automatically with
  unknown sex.
  """
  @spec add_union(t(), id(), id(), keyword()) :: t()
  def add_union(genogram, partner_a, partner_b, opts \\ [])

  def add_union(%__MODULE__{} = genogram, partner_a, partner_b, opts) do
    type = Keyword.get(opts, :type, :marriage)

    if type not in @union_types do
      raise ArgumentError,
            "unknown union type #{inspect(type)}, expected one of #{inspect(@union_types)}"
    end

    a = to_string(partner_a)
    b = to_string(partner_b)
    children = opts |> Keyword.get(:children, []) |> Enum.map(&to_string/1)
    union = %Union{partners: {a, b}, type: type, children: children}

    genogram
    |> ensure_person(a)
    |> ensure_person(b)
    |> ensure_people(children)
    |> Map.update!(:unions, &(&1 ++ [union]))
  end

  @doc """
  Add an emotional bond between two people.

  `type` must be one of `:close`, `:distant`, `:conflict`, `:hostile`, `:cutoff`,
  `:fused`. People that do not exist yet are created automatically with unknown sex.
  """
  @spec add_bond(t(), id(), id(), Bond.type()) :: t()
  def add_bond(%__MODULE__{} = genogram, person_a, person_b, type) when type in @bond_types do
    a = to_string(person_a)
    b = to_string(person_b)

    genogram
    |> ensure_person(a)
    |> ensure_person(b)
    |> Map.update!(:bonds, &(&1 ++ [%Bond{pair: {a, b}, type: type}]))
  end

  def add_bond(%__MODULE__{}, _a, _b, type) do
    raise ArgumentError,
          "unknown bond type #{inspect(type)}, expected one of #{inspect(@bond_types)}"
  end

  @doc "Render the genogram as [Graphviz DOT](https://graphviz.org) source."
  @spec to_dot(t()) :: String.t()
  def to_dot(%__MODULE__{} = genogram), do: DOT.encode(genogram)

  @doc "Render the genogram as [Mermaid](https://mermaid.js.org) flowchart source."
  @spec to_mermaid(t()) :: String.t()
  def to_mermaid(%__MODULE__{} = genogram), do: Mermaid.encode(genogram)

  @doc """
  Map each person id to its render key (`"p0"`, `"p1"`, …) in insertion order.

  Used by the renderers; exposed for callers writing their own.
  """
  @spec key_map(t()) :: %{optional(String.t()) => String.t()}
  def key_map(%__MODULE__{people: people}) do
    people
    |> Enum.with_index()
    |> Map.new(fn {%Person{id: id}, index} -> {id, "p#{index}"} end)
  end

  # --- internals ---

  defp put_person(people, %Person{id: id} = person) do
    if Enum.any?(people, &(&1.id == id)) do
      Enum.map(people, fn existing -> if existing.id == id, do: person, else: existing end)
    else
      people ++ [person]
    end
  end

  defp ensure_person(%__MODULE__{people: people} = genogram, id) do
    if Enum.any?(people, &(&1.id == id)) do
      genogram
    else
      %{genogram | people: people ++ [%Person{id: id, sex: :unknown}]}
    end
  end

  defp ensure_people(genogram, ids) do
    Enum.reduce(ids, genogram, fn id, acc -> ensure_person(acc, id) end)
  end
end