Animate the fan chart when the root person changes (#1492)

* Animate the fan chart when the root person changes

When the new root person is an ancestor or descendant of the previous one,
the arcs move as in a zoomable sunburst. A layout of the same root person
that arrives while the arcs move joins the movement.

* Address comments
This commit is contained in:
David Straub
2026-10-07 13:42:59 +02:00
committed by GitHub
parent 0c27ba9e58
commit a0acae2032
9 changed files with 678 additions and 80 deletions
+4
View File
@@ -26,7 +26,9 @@
"d3": "^7.9.0",
"d3-array": "^3.2.4",
"d3-axis": "^3.0.0",
"d3-color": "^3.1.0",
"d3-hierarchy": "^3.1.2",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"d3-selection": "^3.0.0",
@@ -6834,6 +6836,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
@@ -6979,6 +6982,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
+3 -1
View File
@@ -142,7 +142,9 @@
"d3": "^7.9.0",
"d3-array": "^3.2.4",
"d3-axis": "^3.0.0",
"d3-color": "^3.1.0",
"d3-hierarchy": "^3.1.2",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"d3-selection": "^3.0.0",
@@ -159,4 +161,4 @@
"pwa-helpers": "^0.9.1",
"tippy.js": "^6.3.7"
}
}
}
+231 -59
View File
@@ -1,5 +1,5 @@
import {arc as d3arc} from 'd3-shape'
import {create} from 'd3-selection'
import {create, local, select} from 'd3-selection'
import {
schemePaired,
interpolateWarm,
@@ -7,6 +7,8 @@ import {
schemeCategory10,
} from 'd3-scale-chromatic'
import {ChartViewport} from './ChartViewport.js'
import {joinWithTransitions, transitionColor} from './animatedJoin.js'
import {invertFrame, placeArc, relateFanLayouts} from './layout/fanLayout.js'
import {chartPalette} from './palette.js'
import {LegendCategorical, LegendColorBar, isHoverDevice} from './util.js'
import {chartNameDisplayFormat, fireEvent} from '../util.js'
@@ -213,9 +215,59 @@ function colorScheme(nodes, color, palette, otherLabel) {
// Offset of the legend from the top left corner of the view, in pixels
const legendOffset = [60, 152]
// Draws layouts from `layoutFan` into an SVG that is created once. Each update
// draws the arcs again; the zoom transform is kept as described for
// `ChartViewport.show`.
// The angles and radii each arc moves `from` and `to`. They can be outside
// the circle while the arc moves in or out of the chart.
const arcMotion = local()
// Returns the arc at `t` of the way from `from` to `to`
const interpolateArc = (from, to, t) => ({
x0: from.x0 + (to.x0 - from.x0) * t,
x1: from.x1 + (to.x1 - from.x1) * t,
y0: from.y0 + (to.y0 - from.y0) * t,
y1: from.y1 + (to.y1 - from.y1) * t,
})
// Returns `arc` with its angles within the circle and its radii not negative
function clampArc({x0, x1, y0, y1}) {
const clamp = x => Math.min(Math.max(x, 0), 2 * Math.PI)
return {
x0: clamp(x0),
x1: clamp(x1),
y0: Math.max(y0, 0),
y1: Math.max(y1, 0),
}
}
// Returns the path generators of the arcs of `layout`, with `padding`
// between them, and of the stripe in the colour of the side at the inner
// edge of each arc
function arcShape(layout, padding) {
// The outermost radius of the layout
const radius = Math.max(...layout.nodes.map(node => node.y1))
const arcOf = outerRadius =>
d3arc()
.startAngle(d => d.x0 - Math.PI / 2)
.endAngle(d => d.x1 - Math.PI / 2)
.padAngle(d => Math.min((d.x1 - d.x0) / 2, (2 * padding) / radius))
.padRadius(radius / 2)
.innerRadius(d => d.y0)
.outerRadius(outerRadius)
return {
arc: arcOf(d => Math.max(d.y0, d.y1 - padding)),
sideStripe: arcOf(d => d.y0 + 3),
}
}
// Draws layouts from `layoutFan` into an SVG that is created once. The zoom
// transform is kept as described for `ChartViewport.show`.
//
// When the root person of a new layout is an ancestor or descendant of the
// previous root person, the arcs move as in a zoomable sunburst: the
// ancestor's arc widens to the full circle, or the full circle narrows to the
// arc of the descendant, and the arcs that are in only one of the layouts
// move in or out of the circle. The origin stays in place if it is in view.
// A layout of the same root person that arrives while the arcs move, such as
// one with more ancestors, joins the movement.
export class FanChart {
constructor() {
this._svg = create('svg')
@@ -225,7 +277,17 @@ export class FanChart {
this._content = this._svg.append('g').attr('id', 'chart-content')
this._viewport = new ChartViewport(this._svg, this._content)
this._legend = this._svg.append('g').attr('id', 'legend')
// The drawn layout, to which the next one is related
this._layout = undefined
// The movement of the arcs while they move: the frame from where they
// started to the current layout, and how far they have moved
this._motion = null
// The arc that last related two root people, or the last clicked arc, as
// described for `relateFanLayouts`. It names both people, so it stays
// valid when the chart is cleared or the data changes.
this._lineageArc = undefined
this._arcShape = null
this._nameOptions = {}
}
get node() {
@@ -236,8 +298,12 @@ export class FanChart {
return this._viewport
}
// Removes the chart and the legend, keeping the zoom transform
// Removes the chart and the legend, keeping the zoom transform. The next
// layout is not related to the removed one.
clear() {
this._content.interrupt('arc')
this._motion = null
this._layout = undefined
this._content.selectChildren().remove()
this._legend.selectChildren().remove()
}
@@ -246,7 +312,9 @@ export class FanChart {
// `fanColorModes` and its legend in the top left corner of the view. A
// legend with more categories than colours ends with `otherLabel`. Without
// `interactive`, arcs have no click or hover handling or cursor. Colours
// other than those of the colour modes come from `palette`.
// other than those of the colour modes come from `palette`. Arcs move and
// change colour over `duration` milliseconds, and names are drawn once the
// arcs have stopped moving.
update(
layout,
{
@@ -257,31 +325,47 @@ export class FanChart {
palette = chartPalette,
padding = 3,
fit = false,
duration = 0,
bboxWidth,
bboxHeight,
} = {}
) {
const newLayout = layout !== this._layout
const relation = relateFanLayouts(this._layout, layout, {
lineageArc: this._lineageArc,
})
this._lineageArc = relation.lineageArc ?? this._lineageArc
this._layout = layout
const root = layout.nodes.find(node => node.generation === 0)
const [root] = layout.nodes
// The origin is the centre of both layouts
const origin = [0, 0]
this._viewport.show({
bounds: layout.bounds,
size: [bboxWidth, bboxHeight],
rootHandle: root?.handle,
positions: new Map(),
rootHandle: root.handle,
candidates:
relation.kind === 'lineage' ? [{key: 'origin', position: origin}] : [],
positions: new Map([['origin', origin]]),
fit,
newLayout,
})
this.clear()
const scheme = colorScheme(layout.nodes, color, palette, otherLabel)
this._drawArcs(layout, {
scheme,
nameDisplayFormat,
this._arcShape = arcShape(layout, padding)
this._nameOptions = {nameDisplayFormat, palette, padding}
const cells = this._joinArcs(layout, relation, duration)
this._colorArcs(cells, {scheme, palette, duration})
setFanInteraction(cells, {
interactive,
palette,
padding,
onClick: arc => {
this._lineageArc = {
descendant: root.handle,
ancestor: arc.handle,
key: arc.key,
}
},
})
const [x, y] = this._viewport.viewStart
this._legend.selectChildren().remove()
this._legend
.attr(
'transform',
@@ -290,57 +374,145 @@ export class FanChart {
.call(scheme.legend)
}
_drawArcs(
layout,
{scheme, nameDisplayFormat, interactive, palette, padding}
) {
// The outermost radius of the layout
const radius = Math.max(...layout.nodes.map(node => node.y1))
const arcOf = outerRadius =>
d3arc()
.startAngle(d => d.x0 - Math.PI / 2)
.endAngle(d => d.x1 - Math.PI / 2)
.padAngle(d => Math.min((d.x1 - d.x0) / 2, (2 * padding) / radius))
.padRadius(radius / 2)
.innerRadius(d => d.y0)
.outerRadius(outerRadius)
const arc = arcOf(d => d.y1 - padding)
// A stripe in the colour of the side at the inner edge of each arc
const sideStripe = arcOf(d => d.y0 + 3)
// Unknown ancestors are not drawn
const cells = this._content
.selectAll('g')
.data(layout.nodes.filter(node => node.person?.profile))
.join('g')
.attr('class', 'fan-cell')
cells
.append('path')
.attr('d', arc)
.attr('fill', scheme.fill)
.attr('fill-opacity', scheme.opacity)
.attr('id', d => d.key)
cells
.filter(d => d.generation > 0)
.append('path')
.attr('d', sideStripe)
.attr('fill', d => (d.side === 'mother' ? palette.sex.F : palette.sex.M))
if (interactive) {
setFanInteraction(cells)
// Joins the arcs of `layout` and moves them. For a layout of the same root
// person, the arcs continue the current movement. Otherwise they start a
// new one from where they are drawn, which is animated for a layout
// related by `relation.kind` 'lineage' and drawn at its end for others.
// Returns the cells of the arcs.
_joinArcs(layout, relation, duration) {
const continues =
duration > 0 && relation.kind === 'sameRoot' && this._motion !== null
const frame = continues ? this._motion.frame : relation.frame
if (!continues) {
// Every arc, also one that is leaving, moves from where it is drawn
const t = this._motion?.t ?? 1
this._content.selectChildren('g').each(function () {
const {from, to} = arcMotion.get(this)
const now = interpolateArc(from, to, t)
arcMotion.set(this, {from: now, to: placeArc(now, frame)})
})
}
appendNames(cells, {nameDisplayFormat, palette, padding})
// New arcs start where they are in the frame the movement started in
const back = invertFrame(frame)
// Unknown ancestors are not drawn
const cells = joinWithTransitions(
this._content,
'.fan-cell',
layout.nodes.filter(node => node.person?.profile),
{
key: node => node.key,
previousKey: relation.key,
enter: enter => {
const entering = enter.append('g').attr('class', 'fan-cell')
entering.append('path').attr('class', 'fan-arc')
entering.append('path').attr('class', 'fan-side')
return entering.each(function (d) {
arcMotion.set(this, {from: placeArc(d, back), to: d})
})
},
exit: exit => {
exit.selectAll('text').remove()
exit.select('.fan-arc').attr('id', null)
},
duration,
}
).each(function (d) {
arcMotion.set(this, {...arcMotion.get(this), to: d})
})
cells.select('.fan-arc').attr('id', d => d.key)
cells.selectAll('text').remove()
if (continues) {
this._drawArcs(this._motion.t)
} else if (duration > 0 && relation.kind === 'lineage') {
this._startMotion(frame, duration)
} else {
this._content.interrupt('arc')
this._motion = null
this._drawArcs(1)
this._drawNames()
}
return cells
}
// Fills the arcs with the colours of `scheme`, changing over `duration`
// milliseconds, and colours the side stripes
_colorArcs(cells, {scheme, palette, duration}) {
const arcs = cells.select('.fan-arc').interrupt('fill')
// New arcs start with their colour
arcs
.filter(function () {
return !this.hasAttribute('fill')
})
.attr('fill', scheme.fill)
if (duration > 0) {
const fill = arcs.transition('fill').duration(duration)
transitionColor(fill, 'fill', scheme.fill)
fill.attr('fill-opacity', scheme.opacity)
} else {
arcs.attr('fill', scheme.fill).attr('fill-opacity', scheme.opacity)
}
cells
.select('.fan-side')
.attr('display', d => (d.generation > 0 ? null : 'none'))
.attr('fill', d => (d.side === 'mother' ? palette.sex.F : palette.sex.M))
}
// Moves the arcs over `duration` milliseconds, and draws the names when
// they stop. `frame` maps where the arcs start to the current layout.
_startMotion(frame, duration) {
const motion = {frame, t: 0}
this._motion = motion
this._content
.interrupt('arc')
.transition('arc')
.duration(duration)
.tween('arc', () => t => {
motion.t = t
this._drawArcs(t)
})
.on('end', () => {
this._motion = null
this._drawNames()
})
}
// Draws each arc, also those that are leaving, at `t` of its movement
_drawArcs(t) {
const {arc, sideStripe} = this._arcShape
this._content.selectChildren('g').each(function () {
const {from, to} = arcMotion.get(this)
const clamped = clampArc(interpolateArc(from, to, t))
const cell = select(this)
cell.select('.fan-arc').attr('d', arc(clamped))
cell.select('.fan-side').attr('d', sideStripe(clamped))
})
}
_drawNames() {
const cells = this._content.selectChildren('.fan-cell')
cells.selectAll('text').remove()
appendNames(cells, this._nameOptions)
}
}
// Selects the person of an arc when it is clicked, and shows a preview of
// the person while the pointer is on it
function setFanInteraction(cells) {
// Selects the person of an arc when it is clicked, after calling
// `onClick(node)` with the node of the arc, and shows a preview of the person
// while the pointer is on it. Without `interactive`, arcs have no click or
// hover handling.
function setFanInteraction(cells, {interactive, onClick}) {
if (!interactive) {
cells
.style('cursor', null)
.on('click', null)
.on('mouseenter', null)
.on('mouseleave', null)
return
}
cells
.style('cursor', 'pointer')
.on('click', function (event, d) {
onClick(d)
fireEvent(this, 'pedigree:person-selected', {
grampsId: d.person?.gramps_id,
})
+38 -19
View File
@@ -1,3 +1,5 @@
import {color} from 'd3-color'
import {interpolateRgb} from 'd3-interpolate'
import {local} from 'd3-selection'
// The key each element was last joined with
@@ -25,6 +27,19 @@ export const translate = ([x, y]) => `translate(${x},${y})`
export const interpolatePoint = (a, b) => t =>
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
// Changes the colour attribute `name` of the elements of `transition` to
// `value(d)`. Colours blend only when both are colours that d3 can read.
// Others, such as CSS variables, which d3 would blend as text into names that
// do not exist, change at the start.
export function transitionColor(transition, name, value) {
transition.attrTween(name, function (d) {
const from = color(this.getAttribute(name))
const to = value(d)
const toColor = color(to)
return from && toColor ? interpolateRgb(from, toColor) : () => to
})
}
// Moves each element to `position(d)`. With a `duration`, elements move there
// from `start(d)`. Interpolating the points keeps the transform in the format
// that `elementPosition` reads while they move.
@@ -56,7 +71,9 @@ export function currentPositions(parent, selector) {
// Joins `data` to the children of `parent` that match `selector`, matching
// elements and data by `key(d)`. An element keeps the key it was joined with,
// so it is reused whenever that key is in `data` again, even with a different
// data object. `enter(selection)` appends and returns the new elements, and
// data object. `previousKey(k)` gives the key in `data` of the element that
// was last joined with key `k`, and elements for which it is undefined leave.
// `enter(selection)` appends and returns the new elements, and
// `exit(selection)` can move the leaving elements before they go.
//
// Leaving elements lose their classes, so that later joins ignore them, and
@@ -67,8 +84,25 @@ export function joinWithTransitions(
parent,
selector,
data,
{key, enter, exit = () => {}, duration = 0}
{key, previousKey = k => k, enter, exit = () => {}, duration = 0}
) {
const leave = selection => {
// A leaving element may still be moving or fading in
selection.interrupt().interrupt('fade')
exit(selection)
// Leaving elements no longer respond to clicks or hovering
selection.attr('class', null).style('pointer-events', 'none')
if (duration > 0) {
selection.transition().duration(duration).style('opacity', 0).remove()
} else {
selection.remove()
}
}
leave(
parent.selectChildren(selector).filter(function () {
return previousKey(keyOf(this)) === undefined
})
)
const parentNode = parent.node()
let entering
const elements = parent
@@ -76,7 +110,7 @@ export function joinWithTransitions(
.data(data, function (d) {
// d3 calls this with the parent for each new datum, and with each
// existing element, whose datum is still the one from its last join
return this === parentNode ? key(d) : keyOf(this)
return this === parentNode ? key(d) : previousKey(keyOf(this))
})
.join(
enterSelection => {
@@ -84,22 +118,7 @@ export function joinWithTransitions(
return entering
},
update => update,
exitSelection => {
// A leaving element may still be moving or fading in
exitSelection.interrupt().interrupt('fade')
exit(exitSelection)
// Leaving elements no longer respond to clicks or hovering
exitSelection.attr('class', null).style('pointer-events', 'none')
if (duration > 0) {
exitSelection
.transition()
.duration(duration)
.style('opacity', 0)
.remove()
} else {
exitSelection.remove()
}
}
leave
)
.each(function (d) {
joinKey.set(this, key(d))
+103
View File
@@ -97,3 +97,106 @@ export function layoutFan(graph, handle, {depth, ...options}) {
const {minX, minY, maxX, maxY} = arcBounds(data, arcRadius)
return {nodes, bounds: {xMin: minX, xMax: maxX, yMin: minY, yMax: maxY}}
}
// The frame of two layouts that share their coordinates
const sameFrame = {scale: 1, offset: 0, shift: 0}
// Returns how the arcs of the fan layout `previous` relate to those of
// `layout`. `key(k)` is the key in `layout` of the arc with key `k` in
// `previous`, or undefined if `layout` has no such arc. `frame` maps the arcs
// of `previous` to their place in `layout`: an angle x becomes
// x * scale + offset, and a radius y becomes y + shift. `kind` is 'sameRoot'
// for layouts of the same root person, 'lineage' when the root person of one
// layout is an ancestor of the other, and 'unrelated' otherwise.
//
// When the root person of one layout is an ancestor of the root person of
// the other, the ancestor's arc in one layout is the full circle in the
// other, and `lineageArc` of the relation is that arc: the `key` of the arc
// of the `ancestor` in the layout of the `descendant`, where both are
// handles. An ancestor with several arcs is related by the arc
// `lineageArc` of the options if it is about the same two people, and
// otherwise by their arc nearest to the centre. Layouts of different people
// that are not related this way have no arcs in common.
export function relateFanLayouts(previous, layout, {lineageArc} = {}) {
const unrelated = {
key: () => undefined,
frame: sameFrame,
kind: 'unrelated',
}
if (!previous) {
return unrelated
}
const [before] = previous.nodes
const [after] = layout.nodes
if (before.handle === after.handle) {
return {key: key => key, frame: sameFrame, kind: 'sameRoot'}
}
// Returns the arc of `ancestor` in the layout of `descendant` with `nodes`.
// The nodes are ordered by generation, so the first arc of an ancestor is
// the one nearest to the centre.
const arcOf = (nodes, descendant, ancestor) => {
if (!ancestor) {
return undefined
}
const preferredKey =
lineageArc?.descendant === descendant && lineageArc?.ancestor === ancestor
? lineageArc.key
: undefined
return (
nodes.find(
node => node.key === preferredKey && node.handle === ancestor
) ?? nodes.find(node => node.generation > 0 && node.handle === ancestor)
)
}
const ancestor = arcOf(previous.nodes, before.handle, after.handle)
if (ancestor) {
const scale = (2 * Math.PI) / (ancestor.x1 - ancestor.x0)
return {
key: key =>
key.startsWith(ancestor.key)
? `p${key.slice(ancestor.key.length)}`
: undefined,
frame: {scale, offset: -ancestor.x0 * scale, shift: -ancestor.y0},
kind: 'lineage',
lineageArc: {
descendant: before.handle,
ancestor: after.handle,
key: ancestor.key,
},
}
}
const previousRoot = arcOf(layout.nodes, after.handle, before.handle)
if (previousRoot) {
return {
key: key => `${previousRoot.key}${key.slice(1)}`,
frame: {
scale: (previousRoot.x1 - previousRoot.x0) / (2 * Math.PI),
offset: previousRoot.x0,
shift: previousRoot.y0,
},
kind: 'lineage',
lineageArc: {
descendant: after.handle,
ancestor: before.handle,
key: previousRoot.key,
},
}
}
return unrelated
}
// Returns the angles and radii of `arc` moved by `frame` from
// `relateFanLayouts`
export function placeArc({x0, x1, y0, y1}, {scale, offset, shift}) {
return {
x0: x0 * scale + offset,
x1: x1 * scale + offset,
y0: y0 + shift,
y1: y1 + shift,
}
}
// Returns the frame that moves arcs back where `frame` moved them from
export function invertFrame({scale, offset, shift}) {
return {scale: 1 / scale, offset: -offset / scale, shift: -shift}
}
+2
View File
@@ -2,6 +2,7 @@ import {html} from 'lit'
import {FanChart} from '../charts/FanChart.js'
import {layoutFan} from '../charts/layout/fanLayout.js'
import {chartTransitionDuration} from '../charts/util.js'
import {GrampsjsChartBase} from './GrampsjsChartBase.js'
// Properties that change the layout of the chart
@@ -63,6 +64,7 @@ class GrampsjsFanChart extends GrampsjsChartBase {
color: this.color || 'default',
nameDisplayFormat: this.nameDisplayFormat,
otherLabel: this._('Other'),
duration: chartTransitionDuration(),
bboxWidth: this.containerWidth,
bboxHeight: this.containerHeight,
}
+26
View File
@@ -0,0 +1,26 @@
import {describe, it, expect} from 'vitest'
import {create} from 'd3-selection'
// Transitions come from d3-transition, which d3-zoom loads
import 'd3-zoom'
import {transitionColor} from '../../src/charts/animatedJoin.js'
// Returns the value of the fill tween of a rect filled with `from` at `t` of
// the way to `to`
const fillAt = (from, to, t) => {
const rect = create('svg').append('rect').attr('fill', from)
const transition = rect.transition()
transitionColor(transition, 'fill', () => to)
return transition.attrTween('fill').call(rect.node(), undefined)(t)
}
describe('transitionColor', () => {
it('blends colours', () => {
expect(fillAt('#000000', '#ffffff', 0.5)).toBe('rgb(128, 128, 128)')
})
it('changes CSS variables at the start', () => {
const variable = 'var(--grampsjs-color-shade-120)'
expect(fillAt('#1f78b4', variable, 0.5)).toBe(variable)
expect(fillAt(variable, '#1f78b4', 0.5)).toBe('#1f78b4')
})
})
+141
View File
@@ -1,4 +1,6 @@
import {describe, it, expect, vi} from 'vitest'
import {select} from 'd3-selection'
import {zoomIdentity, zoomTransform} from 'd3-zoom'
import {FanChart} from '../../src/charts/FanChart.js'
import {FamilyGraph} from '../../src/charts/model/FamilyGraph.js'
import {layoutFan} from '../../src/charts/layout/fanLayout.js'
@@ -126,6 +128,145 @@ describe('FanChart', () => {
expect(cells(chart)).toHaveLength(3)
})
it("widens the father's arc to the circle when he becomes the root person", async () => {
const chart = drawn()
const father = cells(chart)[1]
const arcPath = () => father.querySelector('.fan-arc').getAttribute('d')
const before = arcPath()
const [mother] = cells(chart).slice(2)
chart.update(layoutFan(graph, 'F', {depth: 3}), {...size, duration: 20})
// The arcs start where they were, and names wait until they stop
expect(cells(chart)).toEqual([father])
expect(father.__data__.key).toBe('p')
expect(arcPath()).toBe(before)
expect(texts(chart)).toEqual([])
expect(mother.classList.contains('fan-cell')).toBe(false)
expect(mother.querySelector('[id]')).toBeNull()
await vi.waitFor(() => expect(mother.parentNode).toBeNull())
await vi.waitFor(() => expect(texts(chart)).toEqual(['Smith', 'GivenF']))
const disc = new FanChart()
disc.update(layoutFan(graph, 'F', {depth: 3}), size)
expect(arcPath()).toBe(
disc.node.querySelector('.fan-arc').getAttribute('d')
)
})
it('narrows the circle to the arc of the child when going back', () => {
const chart = new FanChart()
chart.update(layoutFan(graph, 'F', {depth: 3}), size)
const [father] = cells(chart)
chart.update(layout, size)
expect(father.__data__.key).toBe('pf')
expect(father.querySelector('.fan-arc').getAttribute('d')).toBe(
drawn().node.querySelector('path#pf').getAttribute('d')
)
})
it('joins the movement with a layout that arrives while the arcs move', async () => {
// F's father FF is only known once more data arrives
const fF = family('fF', 'FF', '', ['F'])
const more = new FamilyGraph([
person('R', 'Smith', {parentFamily: fR}),
person('F', 'Smith', {parentFamily: fF, families: [fR]}),
person('M', 'Jones', {families: [fR]}),
person('FF', 'Smith', {families: [fF]}),
])
const chart = drawn()
chart.update(layoutFan(graph, 'F', {depth: 3}), {...size, duration: 40})
chart.update(layoutFan(more, 'F', {depth: 3}), {...size, duration: 40})
// FF's arc starts where it would have been in R's chart
const arcPath = key =>
cells(chart)
.find(cell => cell.__data__.key === key)
.querySelector('.fan-arc')
.getAttribute('d')
const ofR = new FanChart()
ofR.update(layoutFan(more, 'R', {depth: 3}), size)
expect(arcPath('pf')).toBe(
ofR.node.querySelector('path#pff').getAttribute('d')
)
expect(texts(chart)).toEqual([])
await vi.waitFor(() =>
expect(texts(chart)).toEqual(['Smith', 'GivenF', 'Smith', 'GivenFF'])
)
})
it('moves leaving arcs on from where they are when the next movement starts', async () => {
const chart = drawn()
const mother = cells(chart)[2]
// The first point of the arc of M
const point = () =>
mother
.querySelector('.fan-arc')
.getAttribute('d')
.match(/-?[\d.]+/g)
.slice(0, 2)
.map(Number)
const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1])
const start = point()
chart.update(layoutFan(graph, 'F', {depth: 3}), {...size, duration: 200})
await new Promise(resolve => setTimeout(resolve, 100))
const halfway = point()
chart.update(layout, {...size, duration: 1000})
await vi.waitFor(() => expect(point()).not.toEqual(halfway))
expect(distance(point(), halfway)).toBeLessThan(
distance(start, halfway) / 2
)
chart.clear()
})
it('keeps the origin in place when the root person moves to a parent', () => {
const chart = drawn()
select(chart.node).call(
chart.viewport._zoom.transform,
zoomIdentity.translate(50, 30)
)
chart.update(layoutFan(graph, 'F', {depth: 3}), size)
const {x, y} = zoomTransform(chart.node)
const [startX, startY] = chart.viewport.viewStart
// The origin was shown 50, 30 from the centre of the view
expect([x - startX - 400, y - startY - 300]).toEqual([50, 30])
})
it('blends arc colours, and changes CSS variables at the start', async () => {
const chart = drawn()
const father = cells(chart)[1].querySelector('.fan-arc')
const before = father.getAttribute('d')
chart.update(layoutFan(graph, 'F', {depth: 3}), {...size, duration: 1000})
await vi.waitFor(() => expect(father.getAttribute('d')).not.toBe(before))
expect(father.getAttribute('fill')).toBe(chartPalette.fanRoot)
chart.clear()
})
it('goes back into the arc the chart zoomed from', () => {
// G is the father of both of R's parents
const fF = family('fF', 'G', '', ['F'])
const fM = family('fM', 'G', '', ['M'])
const collapsed = new FamilyGraph([
person('R', 'Smith', {parentFamily: fR}),
person('F', 'Smith', {parentFamily: fF, families: [fR]}),
person('M', 'Jones', {parentFamily: fM, families: [fR]}),
person('G', 'Smith', {families: [fF, fM]}),
])
const chart = new FanChart()
chart.update(layoutFan(collapsed, 'R', {depth: 3}), size)
const viaMother = cells(chart).find(cell => cell.__data__.key === 'pmf')
viaMother.dispatchEvent(new MouseEvent('click'))
chart.update(layoutFan(collapsed, 'G', {depth: 3}), size)
expect(viaMother.__data__.key).toBe('p')
chart.update(layoutFan(collapsed, 'R', {depth: 3}), size)
expect(viaMother.__data__.key).toBe('pmf')
})
it('does not relate the next layout to a cleared one', () => {
const chart = drawn()
chart.clear()
chart.update(layoutFan(graph, 'F', {depth: 3}), {...size, duration: 1000})
// Without a movement, names are drawn at once
expect(texts(chart)).toEqual(['Smith', 'GivenF'])
chart.clear()
})
it('removes the chart and legend on clear', () => {
const chart = drawn({color: 'surname'})
chart.clear()
+130 -1
View File
@@ -1,6 +1,11 @@
import {describe, it, expect} from 'vitest'
import {FamilyGraph} from '../../src/charts/model/FamilyGraph.js'
import {layoutFan} from '../../src/charts/layout/fanLayout.js'
import {
invertFrame,
layoutFan,
placeArc,
relateFanLayouts,
} from '../../src/charts/layout/fanLayout.js'
const family = (handle, father, mother, children) => ({
handle,
@@ -72,3 +77,127 @@ describe('layoutFan', () => {
expect(alone.bounds).toEqual({xMin: -60, xMax: 60, yMin: -60, yMax: 60})
})
})
describe('relateFanLayouts', () => {
const ofR = layoutFan(graph, 'R', {depth: 3})
const ofF = layoutFan(graph, 'F', {depth: 3})
const node = (layout, key) => layout.nodes.find(n => n.key === key)
const geometry = ({x0, x1, y0, y1}) => ({x0, x1, y0, y1})
const expectArc = (arc, expected) =>
Object.entries(geometry(expected)).forEach(([name, value]) =>
expect(arc[name]).toBeCloseTo(value)
)
it('relates nothing without a previous layout', () => {
const relation = relateFanLayouts(undefined, ofR)
expect(relation.key('p')).toBeUndefined()
expect(relation.kind).toBe('unrelated')
})
it('keeps keys and places for the same root person', () => {
const relation = relateFanLayouts(ofR, layoutFan(graph, 'R', {depth: 4}))
expect(relation.key('pfm')).toBe('pfm')
expect(relation.kind).toBe('sameRoot')
expectArc(placeArc(node(ofR, 'pm'), relation.frame), node(ofR, 'pm'))
})
it("widens the father's arc to the circle when he becomes the root person", () => {
const relation = relateFanLayouts(ofR, ofF)
expect(relation.kind).toBe('lineage')
expect(['p', 'pf', 'pff', 'pm', 'pfm'].map(relation.key)).toEqual([
undefined,
'p',
'pf',
undefined,
'pm',
])
for (const key of ['pf', 'pff', 'pfm']) {
expectArc(
placeArc(node(ofR, key), relation.frame),
node(ofF, relation.key(key))
)
}
})
it('narrows the circle to the arc of the child when going back', () => {
const relation = relateFanLayouts(ofF, ofR)
expect(relation.kind).toBe('lineage')
expect(['p', 'pf', 'pmm'].map(relation.key)).toEqual(['pf', 'pff', 'pfmm'])
for (const key of ['p', 'pf', 'pm']) {
expectArc(
placeArc(node(ofF, key), relation.frame),
node(ofR, relation.key(key))
)
}
})
it("widens a grandparent's arc to the circle, and narrows it back", () => {
const ofFF = layoutFan(graph, 'FF', {depth: 3})
const relation = relateFanLayouts(ofR, ofFF)
expect(['pf', 'pff', 'pffm'].map(relation.key)).toEqual([
undefined,
'p',
'pm',
])
expectArc(placeArc(node(ofR, 'pff'), relation.frame), node(ofFF, 'p'))
const back = relateFanLayouts(ofFF, ofR)
expect(back.key('pm')).toBe('pffm')
expectArc(placeArc(node(ofFF, 'p'), back.frame), node(ofR, 'pff'))
})
describe('an ancestor with several arcs', () => {
// G is the father of both of R's parents
const fF = family('fF', 'G', '', ['F'])
const fM = family('fM', 'G', '', ['M'])
const collapsed = new FamilyGraph([
person('R', {parentFamily: fR}),
person('F', {parentFamily: fF, families: [fR]}),
person('M', {parentFamily: fM, families: [fR]}),
person('G', {families: [fF, fM]}),
])
const ofCollapsedR = layoutFan(collapsed, 'R', {depth: 3})
const ofG = layoutFan(collapsed, 'G', {depth: 3})
const viaMother = {descendant: 'R', ancestor: 'G', key: 'pmf'}
it('is related by their arc nearest to the centre', () => {
const relation = relateFanLayouts(ofCollapsedR, ofG)
expect(relation.key('pff')).toBe('p')
expect(relation.lineageArc).toEqual({
descendant: 'R',
ancestor: 'G',
key: 'pff',
})
})
it('is related by the lineage arc of the same two people', () => {
const relation = relateFanLayouts(ofCollapsedR, ofG, {
lineageArc: viaMother,
})
expect(relation.key('pmf')).toBe('p')
expect(relation.key('pff')).toBeUndefined()
expect(relation.lineageArc).toEqual(viaMother)
const back = relateFanLayouts(ofG, ofCollapsedR, {lineageArc: viaMother})
expect(back.key('p')).toBe('pmf')
expect(back.lineageArc).toEqual(viaMother)
})
it('ignores a lineage arc of other people', () => {
const relation = relateFanLayouts(ofCollapsedR, ofG, {
lineageArc: {...viaMother, descendant: 'F'},
})
expect(relation.key('pff')).toBe('p')
})
})
it('relates nothing for unrelated root people', () => {
const relation = relateFanLayouts(ofF, layoutFan(graph, 'M', {depth: 3}))
expect(relation.key('p')).toBeUndefined()
expect(relation.kind).toBe('unrelated')
})
it('inverts frames', () => {
const {frame} = relateFanLayouts(ofR, ofF)
const arc = node(ofR, 'pff')
expectArc(placeArc(placeArc(arc, frame), invertFrame(frame)), arc)
})
})