mirror of
https://github.com/gramps-project/gramps-web.git
synced 2026-10-16 21:01:38 +08:00
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:
Generated
+4
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user