Draw each person once in the relationship chart (#1495)

* Draw each person once in the relationship chart

People who share a family as partners, directly or through other
partners, form one Graphviz node: a row of cards in family list order
with the family markers in the gaps between them. This replaces the
couple clusters, the placeholder parents and the copies of people with
several families. Partners who are not adjacent are joined by a bracket
below the row, and a child in the same row as their parents is linked
by an arch over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address comment

* Link a child once from a single parent of several families

When several parent families of a child resolve to the same single
fetched parent, they give one link instead of identical links with the
same key. It is Birth if the child is the parent's birth child in any
of the families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Straub
2026-10-07 15:17:37 +02:00
committed by GitHub
parent ae5a5e43c4
commit 7a8082ecab
5 changed files with 746 additions and 297 deletions
+39 -12
View File
@@ -1,12 +1,17 @@
import {linkVertical} from 'd3-shape'
import {ChartCanvas, place} from './ChartCanvas.js'
import {appendFamilyMarker, familyMarkerPosition} from './familyMarker.js'
import {relationshipLayoutDefaults} from './layout/relationshipLayout.js'
import {
archHeight,
relationshipLayoutDefaults,
} from './layout/relationshipLayout.js'
const {boxWidth, boxHeight} = relationshipLayoutDefaults
// A link is drawn as a curve from the start to the end of its route. Links
// from a family start at its marker.
// A link runs from the marker of a family, or the bottom of a single
// parent's card, to the top of the child's card. It is drawn as a curve, or,
// when the child is not below its start, as an arch over the row that ends
// `archHeight` pixels above the child.
const curve = linkVertical()
const markerPosition = familyMarkerPosition(boxHeight)
@@ -45,10 +50,6 @@ export class RelationshipChart extends ChartCanvas {
return link.key
}
drawnNodes(layout) {
return layout.nodes.filter(node => node.kind !== 'placeholder')
}
drawnLinks(layout) {
return layout.links.filter(link => link.points.length > 0)
}
@@ -74,17 +75,21 @@ export class RelationshipChart extends ChartCanvas {
return this.isPerson(node) && node.handle === this._rootHandle
}
linkEnds({source, points}) {
linkEnds({source, target}) {
return [
source.kind === 'family'
? [source.x + markerPosition[0], source.y + markerPosition[1]]
: points[0],
points[points.length - 1],
: [source.x, source.y + boxHeight / 2],
[target.x, target.y - boxHeight / 2],
]
}
linkPath([start, end]) {
return curve({source: start, target: end})
if (end[1] > start[1]) {
return curve({source: start, target: end})
}
const top = end[1] - archHeight
return `M${start[0]},${start[1]}V${top}H${end[0]}V${end[1]}`
}
styleLinks(links, palette) {
@@ -94,12 +99,34 @@ export class RelationshipChart extends ChartCanvas {
)
}
// Family markers are small, so they are redrawn on every update
// Family markers are small, so they are redrawn on every update. The
// marker of a family whose partners are not next to each other sits on a
// bracket that joins the bottoms of their cards.
drawExtras(nodes, {palette}) {
const markers = nodes
.filter(node => node.kind === 'family')
.select('.family-marker')
markers.selectChildren().remove()
markers
.filter(node => node.bracket)
.append('path')
.attr('class', 'bracket')
.attr('fill', 'none')
.attr('stroke', palette.relationshipLink)
.attr('d', ({bracket: {left, right, top}}) =>
[
`M${left},${top}`,
`V${markerPosition[1]}`,
`H${right}`,
`V${top}`,
].join('')
)
appendFamilyMarker(markers, {boxHeight, palette})
// The bracket reaches the marker from one side only, so the marker's
// own line would stick out on the other
markers
.filter(node => node.bracket)
.select('line')
.remove()
}
}
+388 -202
View File
@@ -1,8 +1,14 @@
import {Graphviz} from '@hpcc-js/wasm'
import {familyMarkerPosition} from '../familyMarker.js'
// Layout of the relationship chart. The people are turned into a Graphviz
// graph with a cluster for each couple, laid out by the dot engine and read
// back from its JSON output.
// Layout of the relationship chart. Each person is drawn once. People who
// share a family as partners, directly or through other partners, form a
// partner group: one Graphviz node, an HTML table with a row of cells for
// their cards and the gaps between them, and the space for whatever else is
// drawn above and below the cards. Edges run from the gap between two
// partners, or from the card of a single parent, to the top of each child's
// card. The graph is laid out by the dot engine and read back from its JSON
// output.
// Size of the person cards the layout makes room for, in pixels
export const relationshipLayoutDefaults = {
@@ -10,6 +16,24 @@ export const relationshipLayoutDefaults = {
boxHeight: 90,
}
// Space left and right of each card, and between adjacent cards, in pixels
const cardPadding = 5
const gapWidth = 14
// Vertical distance in pixels from the bottom of a row of cards to the
// bracket of a family whose partners are not adjacent, and between the
// brackets of one partner group
const bracketDepth = 16
// Radius of a family's marker, from `familyMarker.js`, and room for the
// width of lines
const markerRadius = 6
const lineMargin = 2
// Height in pixels of the arch over a partner group that links a child to
// parents in the same group
export const archHeight = 20
// Returns the relation of a child to the parents the child is linked from:
// Birth if it is Birth for all of them, otherwise the first other relation
function childRelation(family, childHandle, parents) {
@@ -22,176 +46,322 @@ function childRelation(family, childHandle, parents) {
return relations.find(relation => relation && relation !== 'Birth') ?? 'Birth'
}
// Returns the Graphviz model of the relationship chart of all people in
// `graph`. A family with both partners known, and each parent family of a
// person, gets a cluster with its known partners; every other person gets a
// cluster of their own. Edges link children to their parent families. People
// without known parents who appear in more than one family get a placeholder
// parent, which keeps their clusters together.
// Returns the order of the cards of a partner group. The person with the
// most partners comes first and gets their first partner on the left and
// the others on the right; a person with one partner gets the father on the
// left. Partners of partners continue on the side they were reached from.
// Returns the handles in the order of the cards and the person in the middle.
function orderGroup(members, partnersOf) {
const [hub] = [...members].sort(
(a, b) => partnersOf(b).length - partnersOf(a).length
)
const row = [hub]
const placed = new Set(row)
const place = (handle, side) => {
for (const {partner} of partnersOf(handle)) {
if (!placed.has(partner)) {
placed.add(partner)
if (side === 'left') {
row.unshift(partner)
} else {
row.push(partner)
}
place(partner, side)
}
}
}
const [first, ...others] = partnersOf(hub)
if (first) {
const isFather = others.length === 0 && first.family.mother_handle === hub
placed.add(first.partner)
if (isFather || others.length > 0) {
row.unshift(first.partner)
place(first.partner, 'left')
} else {
row.push(first.partner)
place(first.partner, 'right')
}
}
place(hub, 'right')
return {row, hub}
}
// Returns the model of the relationship chart of all people in `graph`.
//
// Each of the `groups` has the `members` of a partner group in the order of
// their cards and the `families` between them. A family is `adjacent` when
// it takes the gap between its two partners' cards; any other family is
// drawn as a bracket below the cards, from the inner partner to its marker
// under the `outer` one. Each of the `edges` links a child to a
// parent family: from the family when both parents are known, and from the
// known parent otherwise. No two edges link the same source and child.
export function relationshipModel(graph) {
const people = graph.people()
const known = handle => Boolean(handle) && graph.person(handle) !== undefined
const clusters = new Map()
const clustersOfPerson = new Map()
const edges = new Map()
const addCluster = (key, family, partners, placeholder = false) => {
const members = partners.filter(handle => placeholder || known(handle))
if (members.length === 0) {
// Families with both partners known, by partner, in the partner's order
const couples = new Map()
const seen = new Set()
const addCouple = family => {
if (
seen.has(family.handle) ||
!known(family.father_handle) ||
!known(family.mother_handle) ||
family.father_handle === family.mother_handle
) {
return
}
clusters.set(key, {key, family, members, placeholder})
for (const handle of members) {
const keys = clustersOfPerson.get(handle) ?? new Set()
keys.add(key)
clustersOfPerson.set(handle, keys)
seen.add(family.handle)
for (const [handle, partner] of [
[family.father_handle, family.mother_handle],
[family.mother_handle, family.father_handle],
]) {
couples.set(handle, [...(couples.get(handle) ?? []), {family, partner}])
}
}
// `fromPerson` is the parent the edge starts at, or undefined to start at
// the family between two partners
const addEdge = (familyKey, fromPerson, toPerson, link) => {
edges.set(`${familyKey}__${fromPerson}__${toPerson}`, {
familyKey,
fromPerson,
toPerson,
...link,
})
}
for (const person of people) {
for (const family of graph.partnerFamilies(person.handle)) {
if (known(family.father_handle) && known(family.mother_handle)) {
addCluster(family.handle, family, [
family.father_handle,
family.mother_handle,
])
graph.partnerFamilies(person.handle).forEach(addCouple)
}
for (const person of people) {
graph.parentFamilies(person.handle).forEach(addCouple)
}
// A person's families follow their family list, which Gramps users sort
// by date
for (const [handle, list] of couples) {
const order = graph.partnerFamilies(handle).map(family => family.handle)
const rank = ({family}) => {
const index = order.indexOf(family.handle)
return index === -1 ? order.length : index
}
list.sort((a, b) => rank(a) - rank(b))
}
const couplesOf = handle => couples.get(handle) ?? []
// Each partner once, from their first family with this person
const partnersOf = handle =>
couplesOf(handle).filter(
(couple, i, all) => all.findIndex(c => c.partner === couple.partner) === i
)
const groups = []
const groupOfPerson = new Map()
for (const person of people) {
if (groupOfPerson.has(person.handle)) {
continue
}
const members = [person.handle]
const reached = new Set(members)
for (let i = 0; i < members.length; i += 1) {
for (const {partner} of couplesOf(members[i])) {
if (!reached.has(partner)) {
reached.add(partner)
members.push(partner)
}
}
}
for (const family of graph.parentFamilies(person.handle)) {
addCluster(family.handle, family, [
family.father_handle,
family.mother_handle,
])
const {row, hub} = orderGroup(members, partnersOf)
// Of two partners who are not adjacent, the one with fewer partners is
// the outer one, or else the one further from the middle
const outerOf = (left, right) => {
const weight = index => [
-partnersOf(row[index]).length,
Math.abs(index - row.indexOf(hub)),
]
const [a, b] = [weight(left), weight(right)]
return a[0] > b[0] || (a[0] === b[0] && a[1] > b[1]) ? left : right
}
const group = {index: groups.length, members: row, families: []}
const familySeen = new Set()
for (const handle of row) {
for (const {family} of couplesOf(handle)) {
if (familySeen.has(family.handle)) {
continue
}
familySeen.add(family.handle)
const [left, right] = [
row.indexOf(family.father_handle),
row.indexOf(family.mother_handle),
].sort((a, b) => a - b)
const gapTaken = group.families.some(
other => other.adjacent && other.left === left
)
const adjacent = right === left + 1 && !gapTaken
group.families.push({
family,
left,
right,
adjacent,
outer: adjacent ? undefined : outerOf(left, right),
})
}
}
group.families.sort((a, b) => a.left - b.left || a.right - b.right)
groups.push(group)
for (const handle of row) {
groupOfPerson.set(handle, group)
}
}
for (const person of people) {
if (!clustersOfPerson.has(person.handle)) {
addCluster(`p_${person.handle}`, undefined, [person.handle])
}
}
// Children are linked from the family when both parents are known, and
// from the known parent otherwise
// Families of a child that resolve to the same single parent give one
// edge, which is Birth if the child is the parent's birth child in any of
// them
const edgesByKey = new Map()
for (const person of people) {
for (const family of graph.parentFamilies(person.handle)) {
const parents = [family.father_handle, family.mother_handle].filter(known)
const couple = seen.has(family.handle)
if (parents.length > 0) {
addEdge(
family.handle,
parents.length === 2 ? undefined : parents[0],
person.handle,
{
kind: 'child',
relation: childRelation(family, person.handle, parents),
}
)
const edge = {
family: couple ? family : undefined,
fromPerson: couple ? undefined : parents[0],
toPerson: person.handle,
relation: childRelation(family, person.handle, parents),
}
const key = `${couple ? family.handle : edge.fromPerson}->${
person.handle
}`
const existing = edgesByKey.get(key)
if (!existing) {
edgesByKey.set(key, edge)
} else if (edge.relation === 'Birth') {
existing.relation = 'Birth'
}
}
}
}
const edges = [...edgesByKey.values()]
for (const person of people) {
const hasKnownParents = graph
.parentFamilies(person.handle)
.some(
family => known(family.father_handle) || known(family.mother_handle)
)
const shownFamilies = graph
.partnerFamilies(person.handle)
.filter(
family =>
(known(family.father_handle) && known(family.mother_handle)) ||
(family.child_ref_list ?? []).some(childRef => known(childRef.ref))
)
if (!hasKnownParents && shownFamilies.length > 1) {
const placeholder = `fakeparent${person.handle}`
addCluster(`p_${placeholder}`, undefined, [placeholder], true)
addEdge(`p_${placeholder}`, placeholder, person.handle, {
kind: 'placeholder',
})
// A child in the same group as their parents is linked by an arch
for (const edge of edges) {
const source = groupOfPerson.get(
edge.family ? edge.family.father_handle : edge.fromPerson
)
if (source === groupOfPerson.get(edge.toPerson)) {
source.arch = true
}
}
return {groups, groupOfPerson, edges}
}
// Returns the space a partner group needs above and below its cards: room
// for its brackets below and its arch above. It is the same on both sides,
// so that the cards of all groups in a row stay aligned.
function groupMargin(group) {
const brackets = group.families.filter(({adjacent}) => !adjacent).length
const below =
brackets > 0 ? brackets * bracketDepth + markerRadius + lineMargin : 0
const above = group.arch ? archHeight + lineMargin : 0
return Math.max(below, above)
}
// Returns the port that edges leave a cell by: the cell itself, or the cell
// under it in the space below the cards
const tailPort = (group, port) => (groupMargin(group) > 0 ? `s${port}` : port)
// Returns the cells of a partner group's row: a card for each member and a
// gap between adjacent cards, with their widths and the x of their left
// edges relative to the left edge of the row
function groupCells(group, {boxWidth}) {
const cells = []
let x = 0
group.members.forEach((handle, i) => {
if (i > 0) {
cells.push({port: `g${i - 1}`, x, width: gapWidth})
x += gapWidth
}
const width = boxWidth + 2 * cardPadding
cells.push({port: `p${i}`, x, width})
x += width
})
return {cells, width: x}
}
// Returns the centre x of the card at `index` in a partner group's row,
// relative to the left edge of the row
const cardX = (index, {boxWidth}) =>
index * (boxWidth + 2 * cardPadding + gapWidth) + boxWidth / 2 + cardPadding
// Returns where a family's links start, relative to the left edge of its
// partner group's row, and the port of the cell below that point: the gap
// between adjacent partners, or else under the outer partner's card, on the
// side of the inner one
function familyStart(groupFamily, options) {
const {left, right, adjacent, outer} = groupFamily
if (adjacent) {
return {
x: (cardX(left, options) + cardX(right, options)) / 2,
port: `g${left}`,
}
}
const inward = outer === right ? -1 : 1
return {
clusters: [...clusters.values()],
clustersOfPerson,
edges: [...edges.values()],
x: cardX(outer, options) + (inward * options.boxWidth) / 4,
port: `p${outer}`,
}
}
// Returns the name of a node in the DOT source: a person's box in a cluster,
// or the family between the two partners of a cluster
const nodeName = (clusterKey, handle) =>
handle === undefined ? `node_${clusterKey}` : `node_${clusterKey}x${handle}`
const groupName = group => `group${group.index}`
// Returns the DOT source for a relationship model, with person boxes sized
// for cards of `boxWidth` by `boxHeight` pixels
export function relationshipDot(
model,
{boxWidth, boxHeight} = relationshipLayoutDefaults
) {
const statements = []
for (const cluster of model.clusters) {
const width = cluster.placeholder ? 0 : boxWidth / 66
const height = cluster.placeholder ? 0 : boxHeight / 66 - 0.3
const personNode = (handle, margin) =>
`"${nodeName(
cluster.key,
handle
)}" [margin=${margin} shape="none" fixedsize=true width=${width} height=${height} label=<->]`
const [first, second] = cluster.members
if (second === undefined) {
statements.push(
`subgraph "cluster_${
cluster.key
}" {cluster=true color=white label="." ${personNode(first, 0.25)}}`
)
} else {
statements.push(
`subgraph "cluster_${
cluster.key
}" {cluster=true color=white margin="50,0" label="." ${personNode(
first,
0
)} "${nodeName(
cluster.key
)}" [label=<.> shape="none" margin=0 fixedsize=true width=0.1 height=${height}] ${personNode(
second,
0.25
)}}`
)
}
// Returns the tail and head of the DOT edge for a model edge
function edgeEnds(model, edge, options) {
const target = model.groupOfPerson.get(edge.toPerson)
const head = `"${groupName(target)}":"p${target.members.indexOf(
edge.toPerson
)}":n`
if (edge.family) {
const group = model.groupOfPerson.get(edge.family.father_handle)
const groupFamily = group.families.find(
({family}) => family.handle === edge.family.handle
)
const {port} = familyStart(groupFamily, options)
return {tail: `"${groupName(group)}":"${tailPort(group, port)}":s`, head}
}
for (const edge of model.edges) {
for (const clusterKey of model.clustersOfPerson.get(edge.toPerson) ?? []) {
const target = nodeName(clusterKey, edge.toPerson)
const source = nodeName(edge.familyKey, edge.fromPerson)
const tail = edge.fromPerson === undefined ? `ltail="${source}", ` : ''
statements.push(
`"${source}" -> "${target}" [${tail}label="", arrowhead=none, color="#555"]`
)
}
}
return `digraph gramps {compound=true ranksep=2.8 labelloc="t" charset="UTF-8" pad=2 splines=polyline nslimit=2.0 nodesep=0 ${statements.join(
const group = model.groupOfPerson.get(edge.fromPerson)
const port = `p${group.members.indexOf(edge.fromPerson)}`
return {tail: `"${groupName(group)}":"${tailPort(group, port)}":s`, head}
}
// Returns the DOT source for a relationship model, with cards of `boxWidth`
// by `boxHeight` pixels
export function relationshipDot(model, options = relationshipLayoutDefaults) {
const {boxHeight} = options
const statements = model.groups.map(group => {
const {cells, width} = groupCells(group, options)
const row = (height, prefix = '') =>
`<TR>${cells
.map(
cell =>
`<TD PORT="${prefix}${cell.port}" FIXEDSIZE="TRUE" WIDTH="${cell.width}" HEIGHT="${height}"></TD>`
)
.join('')}</TR>`
const margin = groupMargin(group)
const rows =
margin > 0
? [
`<TR><TD COLSPAN="${cells.length}" FIXEDSIZE="TRUE" WIDTH="${width}" HEIGHT="${margin}"></TD></TR>`,
row(boxHeight),
row(margin, 's'),
]
: [row(boxHeight)]
return `"${groupName(
group
)}" [label=<<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0">${rows.join(
''
)}</TABLE>>]`
})
model.edges.forEach((edge, i) => {
const {tail, head} = edgeEnds(model, edge, options)
statements.push(`${tail} -> ${head} [id="e${i}"]`)
})
return `digraph gramps {ranksep=2.6 nodesep=0.5 pad=2 splines=polyline nslimit=2.0 charset="UTF-8" node [shape=plain] edge [arrowhead=none] ${statements.join(
' '
)}}`
}
// Returns the nodes, links and bounds of a relationship model from the JSON
// output of Graphviz, with y pointing down and the first node of the person
// with `rootHandle` at the origin
function readLayout(output, model, graph, rootHandle) {
// output of Graphviz, with y pointing down and the root person at the origin
function readLayout(output, model, graph, rootHandle, options) {
const {boxHeight} = options
const centres = new Map(
(output.objects ?? [])
.filter(object => object.pos)
@@ -201,45 +371,67 @@ function readLayout(output, model, graph, rootHandle) {
})
)
const nodes = []
const nodesByName = new Map()
const addNode = (name, node) => {
const [x, y] = centres.get(name)
const positioned = {...node, x, y}
nodes.push(positioned)
nodesByName.set(name, positioned)
}
for (const cluster of model.clusters) {
const [first, second] = cluster.members
if (cluster.placeholder) {
addNode(nodeName(cluster.key, first), {
key: `placeholder:${first}`,
kind: 'placeholder',
})
} else {
const personNode = handle => ({
key: `${cluster.key}:${handle}`,
const people = new Map()
const families = new Map()
for (const group of model.groups) {
const [x, y] = centres.get(groupName(group))
const left = x - groupCells(group, options).width / 2
group.members.forEach((handle, i) => {
const node = {
key: `person:${handle}`,
kind: 'person',
handle,
person: graph.person(handle),
family: cluster.family,
})
addNode(nodeName(cluster.key, first), personNode(first))
if (second !== undefined) {
addNode(nodeName(cluster.key), {
key: `family:${cluster.key}`,
kind: 'family',
family: cluster.family,
})
addNode(nodeName(cluster.key, second), personNode(second))
x: left + cardX(i, options),
y,
}
nodes.push(node)
people.set(handle, node)
})
// Shorter brackets lie above longer ones
const depths = new Map(
group.families
.filter(groupFamily => !groupFamily.adjacent)
.sort((a, b) => a.right - a.left - (b.right - b.left))
.map((groupFamily, i) => [groupFamily, i + 1])
)
for (const groupFamily of group.families) {
const start = left + familyStart(groupFamily, options).x
const node = {
key: `family:${groupFamily.family.handle}`,
kind: 'family',
family: groupFamily.family,
x: start,
y,
}
if (!groupFamily.adjacent) {
// The marker sits below the outer partner's card, at the end of a
// bracket from the inner partner's card, drawn relative to the
// family node
const [markerX, markerY] = familyMarkerPosition(boxHeight)
const depth = depths.get(groupFamily) * bracketDepth
node.x = start - markerX
node.y = y + boxHeight / 2 + depth - markerY
const inner =
groupFamily.outer === groupFamily.left
? groupFamily.right
: groupFamily.left
const innerX = left + cardX(inner, options) - node.x
node.bracket = {
left: Math.min(innerX, markerX),
right: Math.max(innerX, markerX),
top: y + boxHeight / 2 - node.y,
}
}
nodes.push(node)
families.set(groupFamily.family.handle, node)
}
}
// The route of each edge, by the names of its tail and head nodes
const names = (output.objects ?? []).map(object => object.name)
// The route of each edge, by its id
const routes = new Map(
(output.edges ?? []).map(edge => [
`${names[edge.tail]}->${names[edge.head]}`,
edge.id,
(edge.pos ?? '')
.split(' ')
.filter(token => token && !/^[se],/.test(token))
@@ -249,29 +441,22 @@ function readLayout(output, model, graph, rootHandle) {
}),
])
)
const links = model.edges.flatMap(edge => {
const sourceName = nodeName(edge.familyKey, edge.fromPerson)
const source = nodesByName.get(sourceName)
return [...(model.clustersOfPerson.get(edge.toPerson) ?? [])].map(
clusterKey => {
const targetName = nodeName(clusterKey, edge.toPerson)
const target = nodesByName.get(targetName)
return {
key: `${source.key}->${target.key}`,
kind: edge.kind,
relation: edge.relation,
source,
target,
points: routes.get(`${sourceName}->${targetName}`) ?? [],
}
}
)
const links = model.edges.map((edge, i) => {
const source = edge.family
? families.get(edge.family.handle)
: people.get(edge.fromPerson)
const target = people.get(edge.toPerson)
return {
key: `${source.key}->${target.key}`,
kind: 'child',
relation: edge.relation,
source,
target,
points: routes.get(`e${i}`) ?? [],
}
})
const root = nodes.find(
node => node.kind === 'person' && node.handle === rootHandle
)
const root = people.get(rootHandle)
const [originX, originY] = root ? [root.x, root.y] : [0, 0]
for (const node of nodes) {
node.x -= originX
@@ -298,23 +483,24 @@ let graphvizLoading
// Lays out the relationship chart of all people in `graph` around the person
// with `rootHandle`, and returns a promise of `{nodes, links, bounds, root}`
// in pixels, with y pointing down and `root`, the first node of the root
// person, at the origin.
// in pixels, with y pointing down and `root`, the root person's node, at the
// origin.
//
// Each node has a unique `key`, a `kind` and its centre `x`, `y`. A `person`
// node has the person's `handle` and `person` object and the `family` of the
// cluster it is drawn in; a person in several families has one node per
// family. A `family` node lies between the two partners of its `family`. A
// `placeholder` node is an invisible parent that keeps a person's families
// together. Each link has a `source` and a `target` node, the `points` of the
// route Graphviz found from source to target, and a `kind`: `child`, with the
// child's `relation` to the linked parents, or `placeholder`.
// node has the person's `handle` and `person` object; each person has one.
// A `family` node has its `family`, whose marker is drawn relative to it. It
// lies in the gap between the cards of its two partners, unless they are not
// next to each other: then its marker lies below the outer partner's card,
// and its `bracket` gives the `left` and `right` x and the `top` y of the
// bracket from the other partner's card to the marker and up to the card
// above it, relative to the node. Each link joins a `source`,
// a family or a single parent, to a child `target`, has the `points` of the
// route Graphviz found, a `kind` of `child` and the child's `relation` to the
// linked parents.
export async function layoutRelationships(graph, rootHandle, options = {}) {
const settings = {...relationshipLayoutDefaults, ...options}
const model = relationshipModel(graph)
const dot = relationshipDot(model, {
...relationshipLayoutDefaults,
...options,
})
const dot = relationshipDot(model, settings)
graphvizLoading ??= Graphviz.load().catch(error => {
// A failed load is tried again by the next layout
graphvizLoading = undefined
@@ -322,5 +508,5 @@ export async function layoutRelationships(graph, rootHandle, options = {}) {
})
const graphviz = await graphvizLoading
const output = JSON.parse(graphviz.layout(dot, 'json', 'dot'))
return readLayout(output, model, graph, rootHandle)
return readLayout(output, model, graph, rootHandle, settings)
}
+109 -31
View File
@@ -79,20 +79,19 @@ describe('RelationshipChart', () => {
chart.update(await layoutRelationships(graph, 'R'), size)
const people = [...chart.node.querySelectorAll('.node.person')]
expect(people.map(node => node.__data__.key).sort()).toEqual([
'fA:A1',
'fA:A2',
'fFM:F',
'fFM:M',
'fRS:R',
'fRS:S',
'fRT:R',
'fRT:T',
'p_K:K',
'p_L:L',
'person:A1',
'person:A2',
'person:F',
'person:K',
'person:L',
'person:M',
'person:R',
'person:S',
'person:T',
])
expect(
people.filter(node => node.style.filter).map(node => node.__data__.key)
).toEqual(['fRS:R', 'fRT:R'])
).toEqual(['person:R'])
expect(people.every(node => node.querySelector('text'))).toBe(true)
})
@@ -102,10 +101,7 @@ describe('RelationshipChart', () => {
const dashed = [...chart.node.querySelectorAll('path.link')].filter(path =>
path.getAttribute('stroke-dasharray')
)
expect(dashed.map(path => path.__data__.source.key)).toEqual([
'family:fA',
'family:fA',
])
expect(dashed.map(path => path.__data__.source.key)).toEqual(['family:fA'])
})
it('starts the links of a family at its marker', async () => {
@@ -135,8 +131,8 @@ describe('RelationshipChart', () => {
x + Number(box.getAttribute('x')) + Number(box.getAttribute('width')),
]
}
const [, leftEnd] = edges('fRT:R')
const [rightStart] = edges('fRT:T')
const [, leftEnd] = edges('person:R')
const [rightStart] = edges('person:T')
const marker = nodeWithKey(chart, 'family:fRT')
const cx = Number(marker.querySelector('circle.married').getAttribute('cx'))
expect(translateOf(marker)[0] + cx).toBeCloseTo((leftEnd + rightStart) / 2)
@@ -155,28 +151,28 @@ describe('RelationshipChart', () => {
const chart = new RelationshipChart()
const layout = await layoutRelationships(graph, 'R')
chart.update(layout, size)
const node = nodeWithKey(chart, 'fRT:T')
const node = nodeWithKey(chart, 'person:T')
const text = node.querySelector('text')
chart.update(layout, {...size, bboxWidth: 2000})
expect(nodeWithKey(chart, 'fRT:T')).toBe(node)
expect(nodeWithKey(chart, 'person:T')).toBe(node)
expect(node.querySelector('text')).toBe(text)
})
it('keeps the clicked node of a person with several families in place', async () => {
it('keeps the clicked person in place', async () => {
const chart = new RelationshipChart()
chart.update(await layoutRelationships(graph, 'F'), size)
const before = viewPosition(chart, 'fRT:R')
click(nodeWithKey(chart, 'fRT:R'))
chart.update(await layoutRelationships(graph, 'R'), size)
expectClose(viewPosition(chart, 'fRT:R'), before)
const before = viewPosition(chart, 'person:T')
click(nodeWithKey(chart, 'person:T'))
chart.update(await layoutRelationships(graph, 'T'), size)
expectClose(viewPosition(chart, 'person:T'), before)
})
it('keeps the first visible node of a new root person in place', async () => {
it('keeps a new root person in place', async () => {
const chart = new RelationshipChart()
chart.update(await layoutRelationships(graph, 'F'), size)
const before = viewPosition(chart, 'fRS:R')
const before = viewPosition(chart, 'person:R')
chart.update(await layoutRelationships(graph, 'R'), size)
expectClose(viewPosition(chart, 'fRS:R'), before)
expectClose(viewPosition(chart, 'person:R'), before)
})
it('keeps the root person in place when new data puts them in a family', async () => {
@@ -186,10 +182,92 @@ describe('RelationshipChart', () => {
graph.people().filter(p => !['S', 'T', 'K', 'L'].includes(p.handle))
)
chart.update(await layoutRelationships(withoutPartners, 'R'), size)
const before = viewPosition(chart, 'p_R:R')
const node = nodeWithKey(chart, 'person:R')
const before = viewPosition(chart, 'person:R')
chart.update(await layoutRelationships(graph, 'R'), size)
expect(nodeWithKey(chart, 'p_R:R')).toBeUndefined()
expectClose(viewPosition(chart, 'fRS:R'), before)
expect(nodeWithKey(chart, 'person:R')).toBe(node)
expectClose(viewPosition(chart, 'person:R'), before)
})
it('joins partners who are not next to each other with a bracket', async () => {
const fRW = family('fRW', 'R', 'W', [childRef('N')])
const more = new FamilyGraph([
...graph.people().filter(p => p.handle !== 'R'),
person('R', {
primary_parent_family: fFM,
parent_families: [fFM, fA],
families: [fRS, fRT, fRW],
}),
person('W', {families: [fRW]}),
person('N', {primary_parent_family: fRW}),
])
const chart = new RelationshipChart()
chart.update(await layoutRelationships(more, 'R'), size)
const brackets = [...chart.node.querySelectorAll('path.bracket')]
expect(brackets.map(path => path.parentNode.__data__.key)).toEqual([
'family:fRW',
])
const marker = nodeWithKey(chart, 'family:fRW')
const [x, y] = translateOf(marker)
const ring = marker.querySelector('circle.married')
const [cx, cy] = ['cx', 'cy'].map(name => Number(ring.getAttribute(name)))
const link = [...chart.node.querySelectorAll('path.link')].find(
path => path.__data__.source.key === 'family:fRW'
)
const [, startX, startY] = /^M([^,]+),([^C]+)/.exec(link.getAttribute('d'))
expectClose([Number(startX), Number(startY)], [x + cx, y + cy])
expect(brackets[0].getAttribute('d')).toContain(`V${cy}`)
})
it('draws an arch to a child in the same row as the parents', async () => {
// M marries the widow W and later her daughter D
const fHW = family('fHW', 'H', 'W', [childRef('D')])
const fMW = family('fMW', 'M', 'W', [])
const fMD = family('fMD', 'M', 'D', [])
const chart = new RelationshipChart()
chart.update(
await layoutRelationships(
new FamilyGraph([
person('H', {families: [fHW]}),
person('W', {families: [fHW, fMW]}),
person('M', {families: [fMW, fMD]}),
person('D', {primary_parent_family: fHW, families: [fMD]}),
]),
'M'
),
size
)
const link = [...chart.node.querySelectorAll('path.link')].find(
path => path.__data__.source.key === 'family:fHW'
)
const [d] = translateOf(nodeWithKey(chart, 'person:D'))
const [h] = translateOf(nodeWithKey(chart, 'person:H'))
expect(d).toBeGreaterThan(h)
// Up from the marker, across above the cards and down into D's card
expect(link.getAttribute('d')).toMatch(
new RegExp(`^M[^,]+,35V-65H${d}V-45$`)
)
})
it('keeps one link from a single parent of several families', async () => {
// C is in two families of M whose fathers are not fetched
const fA = family('fA', 'X', 'M', [childRef('C')])
const fB = family('fB', 'Y', 'M', [childRef('C', 'Stepchild', 'Birth')])
const single = new FamilyGraph([
person('M', {families: [fA, fB]}),
person('C', {primary_parent_family: fA, parent_families: [fA, fB]}),
])
const chart = new RelationshipChart()
chart.update(await layoutRelationships(single, 'M'), size)
const [link, ...others] = chart.node.querySelectorAll('path.link')
expect(others).toHaveLength(0)
expect(link.getAttribute('stroke-dasharray')).toBeNull()
chart.update(await layoutRelationships(single, 'C'), {
...size,
duration: 100,
})
expect([...chart.node.querySelectorAll('path.link')]).toEqual([link])
select(chart.node).selectAll('*').interrupt()
})
it('fits the whole chart into the view when asked', async () => {
@@ -212,7 +290,7 @@ describe('RelationshipChart', () => {
...size,
interactive: false,
})
const root = nodeWithKey(chart, 'fRS:R')
const root = nodeWithKey(chart, 'person:R')
expect(root.style.filter).toBe('')
const selected = []
root.addEventListener('pedigree:person-selected', e =>
+209 -51
View File
@@ -1,6 +1,7 @@
import {describe, it, expect} from 'vitest'
import {FamilyGraph} from '../../src/charts/model/FamilyGraph.js'
import {
archHeight,
layoutRelationships,
relationshipDot,
relationshipModel,
@@ -52,31 +53,120 @@ const graph = new FamilyGraph(people)
describe('relationshipModel', () => {
const model = relationshipModel(graph)
const cluster = key => model.clusters.find(c => c.key === key)
const groupOf = handle => model.groupOfPerson.get(handle)
it('gives each family a cluster with its known partners', () => {
expect(cluster('fRS').members).toEqual(['R', 'S'])
expect(cluster('fFM').members).toEqual(['F', 'M'])
expect(cluster('fA').members).toEqual(['A1', 'A2'])
expect(cluster('fTV').members).toEqual(['V', 'T'])
expect(cluster('p_X').members).toEqual(['X'])
it('puts partners, and partners of partners, into one group', () => {
expect(groupOf('R').members).toEqual(['S', 'R', 'T', 'V'])
expect(groupOf('F').members).toEqual(['F', 'M'])
expect(groupOf('A2').members).toEqual(['A1', 'A2'])
expect(groupOf('X').members).toEqual(['X'])
expect(model.groups.flatMap(group => group.members).sort()).toEqual(
people.map(p => p.handle).sort()
)
})
it('puts a person in one cluster per family', () => {
expect([...model.clustersOfPerson.get('R')]).toEqual(['fRS', 'fRT'])
expect([...model.clustersOfPerson.get('T')]).toEqual(['fRT', 'fTV'])
it('places each family in the gap between its partners', () => {
expect(
groupOf('R').families.map(({family, left, right, adjacent}) => [
family.handle,
left,
right,
adjacent,
])
).toEqual([
['fRS', 0, 1, true],
['fRT', 1, 2, true],
['fTV', 2, 3, true],
])
})
it('puts a person with three partners between the first and the others', () => {
const fRW = family('fRW', 'R', 'W')
const more = new FamilyGraph([
person('R', {families: [fRS, fRT, fRW]}),
person('S', {families: [fRS]}),
person('T', {families: [fRT]}),
person('W', {families: [fRW]}),
])
const [group] = relationshipModel(more).groups
expect(group.members).toEqual(['S', 'R', 'T', 'W'])
expect(group.families.find(f => f.family === fRW)).toMatchObject({
left: 1,
right: 3,
adjacent: false,
})
})
it('orders partners by the family list, whoever is fetched first', () => {
const fRW = family('fRW', 'R', 'W')
const more = new FamilyGraph([
person('W', {families: [fRW]}),
person('T', {families: [fRT]}),
person('R', {families: [fRS, fRT, fRW]}),
person('S', {families: [fRS]}),
])
expect(relationshipModel(more).groups[0].members).toEqual([
'S',
'R',
'T',
'W',
])
})
it('puts the marker of a bracket under the partner with fewer partners', () => {
const fRW = family('fRW', 'R', 'W')
const more = new FamilyGraph([
person('R', {families: [fRS, fRT, fRW]}),
person('S', {families: [fRS]}),
person('T', {families: [fRT]}),
person('W', {families: [fRW]}),
])
const [group] = relationshipModel(more).groups
expect(group.families.find(f => f.family === fRW).outer).toBe(3)
})
it('links a child once from a single parent of several families', () => {
// C is M's birth child without a known father, and M's stepchild with
// an unfetched husband; F is fostered and then adopted by M
const fA = family('fA', undefined, 'M', [childRef('C')])
const fB = family('fB', 'X', 'M', [
childRef('C', 'Stepchild', 'Birth'),
childRef('F', 'Adopted', 'Adopted'),
])
const fF = family('fF', 'Y', 'M', [childRef('F', 'Foster', 'Foster')])
const single = new FamilyGraph([
person('M', {families: [fA, fB, fF]}),
person('C', {primary_parent_family: fA, parent_families: [fA, fB]}),
person('F', {primary_parent_family: fF, parent_families: [fF, fB]}),
])
expect(
relationshipModel(single).edges.map(
({fromPerson, toPerson, relation}) => [fromPerson, toPerson, relation]
)
).toEqual([
['M', 'C', 'Birth'],
['M', 'F', 'Foster'],
])
})
it('puts the father of a single couple on the left', () => {
const fPQ = family('fPQ', 'P', 'Q')
const couple = new FamilyGraph([
person('Q', {families: [fPQ]}),
person('P', {families: [fPQ]}),
])
expect(relationshipModel(couple).groups[0].members).toEqual(['P', 'Q'])
})
it('links children from all parent families with their relation', () => {
const childEdges = model.edges
.filter(edge => edge.kind === 'child')
.map(({familyKey, fromPerson, toPerson, relation}) => [
familyKey,
expect(
model.edges.map(({family, fromPerson, toPerson, relation}) => [
family?.handle,
fromPerson,
toPerson,
relation,
])
expect(childEdges).toEqual([
).toEqual([
['fFM', undefined, 'R', 'Birth'],
['fA', undefined, 'R', 'Adopted'],
['fRS', undefined, 'K', 'Birth'],
@@ -93,42 +183,30 @@ describe('relationshipModel', () => {
])
const [edge] = relationshipModel(halfKnown).edges
expect(edge).toMatchObject({
familyKey: 'fP',
family: undefined,
fromPerson: 'P',
toPerson: 'C',
})
})
it('gives a placeholder parent to people without known parents in several families', () => {
expect(cluster('p_fakeparentT')).toMatchObject({
members: ['fakeparentT'],
placeholder: true,
})
expect(model.edges.filter(edge => edge.kind === 'placeholder')).toEqual([
{
familyKey: 'p_fakeparentT',
fromPerson: 'fakeparentT',
toPerson: 'T',
kind: 'placeholder',
},
])
expect(cluster('p_fakeparentS')).toBeUndefined()
})
})
describe('relationshipDot', () => {
it('links a child to each of its nodes', () => {
const dot = relationshipDot(relationshipModel(graph))
expect(dot).toContain('"node_fFM" -> "node_fRSxR"')
expect(dot).toContain('"node_fFM" -> "node_fRTxR"')
it('makes one node per partner group and links children to their card', () => {
const model = relationshipModel(graph)
const dot = relationshipDot(model)
expect(dot.match(/\[label=</g)).toHaveLength(model.groups.length)
expect(dot).not.toContain('cluster')
const name = handle => `group${model.groupOfPerson.get(handle).index}`
// R is the second card of their group, below the gap between F and M
expect(dot).toContain(`"${name('F')}":"g0":s -> "${name('R')}":"p1":n`)
})
})
describe('layoutRelationships', () => {
it('places the first node of the root person at the origin', async () => {
it('places the root person at the origin', async () => {
const layout = await layoutRelationships(graph, 'R')
expect(layout.root).toMatchObject({kind: 'person', handle: 'R', x: 0, y: 0})
expect(layout.root.key).toBe('fRS:R')
expect(layout.root.key).toBe('person:R')
expect(layout.root.person).toBe(graph.person('R'))
})
@@ -147,39 +225,119 @@ describe('layoutRelationships', () => {
})
})
it('returns person, family and placeholder nodes', async () => {
it('returns one node per person and one per family', async () => {
const layout = await layoutRelationships(graph, 'R')
const kinds = kind => layout.nodes.filter(node => node.kind === kind)
expect(kinds('person').filter(node => node.handle === 'R')).toHaveLength(2)
expect(kinds('family').map(node => node.key)).toContain('family:fA')
expect(kinds('placeholder').map(node => node.key)).toEqual([
'placeholder:fakeparentT',
const keys = kind =>
layout.nodes.filter(node => node.kind === kind).map(node => node.key)
expect(keys('person').sort()).toEqual(
people.map(p => `person:${p.handle}`).sort()
)
expect(keys('family').sort()).toEqual([
'family:fA',
'family:fFM',
'family:fRS',
'family:fRT',
'family:fTV',
])
expect(kinds('person').find(node => node.handle === 'X')).toBeDefined()
})
it('draws partners side by side with their family between them', async () => {
const layout = await layoutRelationships(graph, 'R')
const node = key => layout.nodes.find(n => n.key === key)
const [s, r, t] = ['person:S', 'person:R', 'person:T'].map(node)
expect(s.y).toBe(r.y)
expect(t.y).toBe(r.y)
expect(r.x - s.x).toBeCloseTo(t.x - r.x)
expect(r.x - s.x).toBeGreaterThan(190)
expect(node('family:fRT')).toMatchObject({x: (r.x + t.x) / 2, y: r.y})
})
it('joins partners who are not next to each other with a bracket', async () => {
const fRW = family('fRW', 'R', 'W', [childRef('N')])
const more = new FamilyGraph([
person('R', {families: [fRS, fRT, fRW]}),
person('S', {families: [fRS]}),
person('T', {families: [fRT]}),
person('W', {families: [fRW]}),
person('N', {primary_parent_family: fRW}),
])
const layout = await layoutRelationships(more, 'R')
const node = key => layout.nodes.find(n => n.key === key)
const marker = node('family:fRW')
// The bracket runs from R's card to the marker under the left part of
// W's card
const {left, right, top} = marker.bracket
expect(marker.x + left).toBeCloseTo(0)
expect(marker.x + right).toBeCloseTo(node('person:W').x - 190 / 4)
expect(marker.y + top).toBeCloseTo(45)
expect(marker.y).toBeGreaterThan(0)
expect(node('family:fRT').bracket).toBeUndefined()
expect(node('person:N').y).toBeGreaterThan(marker.y)
})
it('makes room for brackets below the bottom row', async () => {
// R has four wives and is in the bottom row with a sister
const fP = family('fP', 'F', 'M', [childRef('R'), childRef('Q')])
const wives = ['S', 'T', 'W', 'Z']
const fams = wives.map(wife => family(`fR${wife}`, 'R', wife))
const more = new FamilyGraph([
person('F', {families: [fP]}),
person('M', {families: [fP]}),
person('R', {primary_parent_family: fP, families: fams}),
person('Q', {primary_parent_family: fP}),
...wives.map((wife, i) => person(wife, {families: [fams[i]]})),
])
const {nodes, bounds} = await layoutRelationships(more, 'R')
const node = key => nodes.find(n => n.key === key)
expect(node('person:Q').y).toBe(node('person:R').y)
const markers = nodes.filter(n => n.bracket)
expect(markers).toHaveLength(2)
for (const marker of markers) {
// The bottom of the ring, relative to the node, is 35 + 6 pixels down
expect(marker.y + 41).toBeLessThanOrEqual(bounds.yMax)
}
})
it('makes room for an arch above the top row', async () => {
// M marries the widow W and later her daughter D
const fHW = family('fHW', 'H', 'W', [childRef('D')])
const fMW = family('fMW', 'M', 'W')
const fMD = family('fMD', 'M', 'D')
const {nodes, bounds} = await layoutRelationships(
new FamilyGraph([
person('H', {families: [fHW]}),
person('W', {families: [fHW, fMW]}),
person('M', {families: [fMW, fMD]}),
person('D', {primary_parent_family: fHW, families: [fMD]}),
]),
'M'
)
const d = nodes.find(n => n.key === 'person:D')
expect(d.y - 45 - archHeight).toBeGreaterThanOrEqual(bounds.yMin)
})
it('draws parents above their children', async () => {
const layout = await layoutRelationships(graph, 'R')
const node = key => layout.nodes.find(n => n.key === key)
expect(node('fFM:F').y).toBeLessThan(node('fRS:R').y)
expect(node('fRS:R').y).toBeLessThan(node('p_K:K').y)
expect(node('person:F').y).toBeLessThan(node('person:R').y)
expect(node('person:R').y).toBeLessThan(node('person:K').y)
})
it('links children with their relation', async () => {
const layout = await layoutRelationships(graph, 'R')
const adopted = layout.links.filter(link => link.relation === 'Adopted')
expect(adopted.map(link => [link.source.key, link.target.key])).toEqual([
['family:fA', 'fRS:R'],
['family:fA', 'fRT:R'],
['family:fA', 'person:R'],
])
})
it('returns the route of each link from its source down to its target', async () => {
it('returns the route of each link from its source down to the top of its target', async () => {
const layout = await layoutRelationships(graph, 'R')
for (const link of layout.links) {
expect(link.points.length).toBeGreaterThan(1)
expect(link.points[0][1]).toBeGreaterThanOrEqual(link.source.y)
expect(link.points.at(-1)[1]).toBeLessThanOrEqual(link.target.y)
expect(link.points.at(-1)[0]).toBeCloseTo(link.target.x)
expect(link.points.at(-1)[1]).toBeCloseTo(link.target.y - 45)
}
})
+1 -1
View File
@@ -9,7 +9,7 @@ vi.mock('@hpcc-js/wasm', () => ({Graphviz: {load: vi.fn()}}))
const graph = new FamilyGraph([{handle: 'X', gramps_id: 'I_X'}])
const output = {
bb: '0,0,20,10',
objects: [{name: 'node_p_XxX', pos: '10,5'}],
objects: [{name: 'group0', pos: '10,5'}],
edges: [],
}