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