d.x0 - Math.PI / 2) // shifted by 90°
+ .endAngle(d => d.x1 - Math.PI / 2) // shifted by 90°
+ .padAngle(d => Math.min((d.x1 - d.x0) / 2, (2 * padding) / radius))
+ .padRadius(radius / 2)
+ .innerRadius(d => d.y0)
+ .outerRadius(d => d.y1 - padding)
+
+ // Construct an arc generator
+ const arcStroke = d3arc()
+ .startAngle(d => d.x0 - Math.PI / 2) // shifted by 90°
+ .endAngle(d => d.x1 - Math.PI / 2) // shifted by 90°
+ .padAngle(d => Math.min((d.x1 - d.x0) / 2, (2 * padding) / radius))
+ .padRadius(radius / 2)
+ .innerRadius(d => d.y0)
+ .outerRadius(d => d.y0 + 3)
+
+ const svg = create('svg')
+ .attr('viewBox', [
+ marginRight - marginLeft - width / 2,
+ marginBottom - marginTop - height / 2,
+ width,
+ height,
+ ])
+ .attr('width', width)
+ .attr('height', height)
+ .attr('style', 'max-width: 100%; height: auto; height: intrinsic;')
+ .attr('font-family', 'Inter var')
+ .attr('font-size', 12)
+ .attr('text-anchor', 'middle')
+
+ const cell = svg.selectAll('a').data(root.descendants()).join('a')
+
+ function arcVisible(d) {
+ return d.name_given !== null
+ }
+
+ cell
+ .filter(d => arcVisible(d.data))
+ .append('path')
+ .attr('d', arc)
+ .attr('fill', d => (color === null ? 'rgb(150, 150, 150)' : color(d)))
+ .attr('fill-opacity', 0.2)
+ .attr('id', d => d.data.id) // Unique id for each slice
+
+ cell
+ .filter(d => d.depth > 0)
+ .filter(d => arcVisible(d.data))
+ .append('path')
+ .attr('d', arcStroke)
+ .attr('fill', d =>
+ d.data.id.slice(-1) === 'm' ? 'var(--color-girl)' : 'var(--color-boy)'
+ )
+
+ function clicked(event, d) {
+ dispatchEvent(
+ new CustomEvent('pedigree:person-selected', {
+ bubbles: true,
+ composed: true,
+ detail: {grampsId: d.data?.person?.gramps_id},
+ })
+ )
+ }
+
+ cell
+ .filter(d => arcVisible(d.data))
+ .style('cursor', 'pointer')
+ .on('click', clicked)
+
+ const fontSize = d => Math.min(12, (((d.y0 + d.y1) / 2) * (d.x1 - d.x0)) / 10)
+
+ const clipString = (s, d, isCenter = false) => {
+ const length = isCenter
+ ? 2 * d.y1
+ : ((d.x1 - d.x0) * (d.y1 + d.y0)) / 2 - padding
+ const nChar = length / (fontSize(d) * 0.6)
+ if (s.length <= nChar) {
+ return s
+ }
+ if (nChar < 2) {
+ return ''
+ }
+ return `${s.slice(0, nChar - 2)}…`
+ }
+
+ cell
+ .filter(d => d.depth === 0)
+ .append('text')
+ .attr('font-weight', '500')
+ .attr('dy', '-0.6em')
+ .text(d => clipString(d.data.name_surname, d, true))
+
+ cell
+ .filter(d => d.depth === 0)
+ .append('text')
+ .attr('font-weight', '300')
+ .attr('dy', '0.6em')
+ .text(d => clipString(d.data.name_given, d, true))
+
+ const startOffset = d =>
+ d.x0 >= Math.PI
+ ? (d.y1 + d.y0 / 2) * (d.x1 - d.x0) + (d.y1 - d.y0) - 3.5 * padding
+ : (d.y1 * (d.x1 - d.x0)) / 2 - padding
+
+ cell
+ .filter(d => d.depth > 0)
+ .filter(d => ((d.y0 + d.y1) / 2) * (d.x1 - d.x0) > 50)
+ .append('text')
+ .attr('font-weight', '500')
+ .attr('font-size', fontSize)
+ .attr('dy', d => (d.y1 - d.y0) / 2 - 7 + 3)
+ // .attr("dx", (dx => 1)
+ .append('textPath') // append a textPath to the text element
+ .attr('xlink:href', d => `#${d.data.id}`)
+ .style('text-anchor', 'middle')
+ .attr('startOffset', startOffset)
+ .style('letter-spacing', d =>
+ d.x0 < Math.PI ? `${(1 / d.y1) * 20}em` : `-${(1 / d.y1) * 10}em`
+ )
+ .text(d => clipString(d.data.name_surname || '', d))
+
+ cell
+ .filter(d => d.depth > 0)
+ .filter(d => ((d.y0 + d.y1) / 2) * (d.x1 - d.x0) > 50)
+ .append('text')
+ .attr('font-weight', '300')
+ .attr('font-size', fontSize)
+ .attr('dy', d => (d.y1 - d.y0) / 2 + 7 + 3)
+ // .attr("dx", (dx => 1)
+ .append('textPath') // append a textPath to the text element
+ .attr('xlink:href', d => `#${d.data.id}`)
+ .style('text-anchor', 'middle')
+ .attr('startOffset', startOffset)
+ .style('letter-spacing', d =>
+ d.x0 < Math.PI ? `${(1 / d.y1) * 40}em` : `-${(1 / d.y1) * 15}em`
+ )
+ .text(
+ d => clipString(d.data.name_given || '', d)
+ // .slice(0, Math.floor(d.y1 * (d.x1 - d.x0) / 10))
+ )
+
+ return svg.node()
+}
diff --git a/src/charts/TreeChart.js b/src/charts/TreeChart.js
new file mode 100644
index 00000000..6c2ab9e6
--- /dev/null
+++ b/src/charts/TreeChart.js
@@ -0,0 +1,257 @@
+import {create} from 'd3-selection'
+import {hierarchy, tree} from 'd3-hierarchy'
+import {curveBumpX, link, symbolTriangle, symbol} from 'd3-shape'
+
+export function TreeChart(
+ data,
+ {
+ depth = 3,
+ padding = 20, // horizontal padding for first and last column
+ gapX = 20, // horizontal gap between boxes
+ gapY = 5, // vertical gap between boxes
+ stroke = '#555', // stroke for links
+ strokeWidth = 1, // stroke width for links
+ strokeOpacity = 0.4, // stroke opacity for links
+ strokeLinejoin, // stroke line join for links
+ strokeLinecap, // stroke line cap for links
+ curve = curveBumpX, // curve for the link
+ boxWidth = 190,
+ boxHeight = 90,
+ imgPadding = 10,
+ childrenTriangle = true,
+ getImageUrl = null,
+ } = {}
+) {
+ // Create a hierarchical data structure based on the input data
+ const root = hierarchy(data)
+
+ const descendants = root.descendants()
+
+ tree()
+ .nodeSize([boxHeight + gapY, boxWidth + gapX])
+ .separation((a, b) => (a.parent === b.parent ? 1 : 1))(root)
+
+ // Center the tree.
+ let x0 = Infinity
+ let x1 = -x0
+ root.each(d => {
+ if (d.x > x1) x1 = d.x
+ if (d.x < x0) x0 = d.x
+ })
+
+ // Compute the default height.
+ // if (height === undefined) height = x1 - x0 + dx * 2;
+
+ // Use the required curve
+ if (typeof curve !== 'function') throw new Error('Unsupported curve')
+
+ const width = depth * boxWidth + (depth - 1) * gapX + 2 * padding
+ const height = boxHeight * 2 ** (depth - 1) + (2 ** (depth - 1) - 1) * gapY
+
+ const svg = create('svg')
+ .attr('viewBox', [-boxWidth / 2 - padding, -height / 2, width, height])
+ .attr('width', width)
+ .attr('height', height)
+ .attr('style', 'max-width: 100%; height: auto; height: intrinsic;')
+ .attr('font-family', 'Inter var')
+ .attr('font-size', 13)
+
+ svg
+ .append('g')
+ .attr('fill', 'none')
+ .attr('stroke', stroke)
+ .attr('stroke-opacity', strokeOpacity)
+ .attr('stroke-linecap', strokeLinecap)
+ .attr('stroke-linejoin', strokeLinejoin)
+ .attr('stroke-width', strokeWidth)
+ .selectAll('path')
+ .data(root.links())
+ .join('path')
+ .attr(
+ 'd',
+ link(curve)
+ .x(d => d.y)
+ .y(d => d.x)
+ )
+
+ const node = svg
+ .append('g')
+ .selectAll('a')
+ .data(descendants)
+ .join('a')
+ .attr('transform', d => `translate(${d.y},${d.x})`)
+
+ node
+ .append('rect')
+ .filter(d => d.data.person)
+ .attr('fill', d =>
+ d.data.id.slice(-1) === 'm' ? 'var(--color-girl)' : 'var(--color-boy)'
+ )
+ .attr('width', 24)
+ .attr('height', boxHeight - 1)
+ .attr('rx', 12)
+ .attr('ry', 12)
+ .attr(
+ 'transform',
+ `translate(${-boxWidth / 2 - 4},${-boxHeight / 2 + 0.5})`
+ )
+ .attr('id', d => d.data.id) // Unique id for each rect
+
+ function clicked(event, d) {
+ dispatchEvent(
+ new CustomEvent('pedigree:person-selected', {
+ bubbles: true,
+ composed: true,
+ detail: {grampsId: d.data?.person?.gramps_id},
+ })
+ )
+ }
+
+ node
+ .append('rect')
+ .filter(d => d.data.person)
+ .attr('fill', 'rgba(230, 230, 230)')
+ .attr('width', boxWidth)
+ .attr('height', boxHeight)
+ .attr('rx', 8)
+ .attr('ry', 8)
+ .attr('transform', `translate(${-boxWidth / 2},${-boxHeight / 2})`)
+ .attr('id', d => d.data.id) // Unique id for each slice
+
+ function triangleClicked(e) {
+ svg.node().dispatchEvent(
+ new CustomEvent('pedigree:show-children', {
+ bubbles: true,
+ composed: true,
+ detail: {pageX: e.pageX, pageY: e.pageY},
+ })
+ )
+ e.stopPropagation()
+ e.preventDefault()
+ }
+
+ if (childrenTriangle) {
+ const triangle = symbol().type(symbolTriangle).size(200)
+
+ node
+ .append('path')
+ .filter(d => d.depth === 0)
+ .attr('d', triangle)
+ .attr(
+ 'transform',
+ d =>
+ `translate(${d.y - boxWidth / 2 - 12},${
+ d.x
+ }) rotate(-90) scale(-1, 0.5)`
+ )
+ .attr('fill', '#bbb')
+ .attr('id', 'triangle-children')
+ .on('click', triangleClicked)
+ }
+
+ const imgRadius = (boxHeight - imgPadding * 2) / 2
+ const textPadding = d =>
+ getImageUrl(d) ? 2 * imgRadius + 2 * imgPadding : 2 * imgPadding
+
+ const clipString = (s, length) => {
+ if (!s) {
+ return ''
+ }
+ const fontSize = 13
+ const nChar = length / (fontSize * 0.6)
+ if (s.length <= nChar) {
+ return s
+ }
+ if (nChar < 2) {
+ return ''
+ }
+ return `${s.slice(0, nChar - 2)}…`
+ }
+
+ const textWidth = d =>
+ getImageUrl(d)
+ ? boxWidth - 2 * imgPadding - 2 * imgRadius
+ : boxWidth - 2 * imgRadius
+
+ node
+ .append('text')
+ .filter(d => d.data.name_surname)
+ .attr('y', -boxHeight / 2 + 25)
+ .attr('x', d => -boxWidth / 2 + textPadding(d))
+ .attr('text-anchor', 'start')
+ .attr('font-weight', '500')
+ .attr('fill', 'rgba(0, 0, 0, 0.9)')
+ .attr('paint-order', 'stroke')
+ .text(d => clipString(`${d.data.name_surname},`, textWidth(d)))
+
+ node
+ .append('text')
+ .filter(d => d.data.name_given)
+ .attr('y', -boxHeight / 2 + 25 + 17)
+ .attr('x', d => -boxWidth / 2 + textPadding(d))
+ .attr('width', 50)
+ .attr('text-anchor', 'start')
+ .attr('font-weight', '500')
+ .attr('fill', 'rgba(0, 0, 0, 0.9)')
+ .attr('paint-order', 'stroke')
+ .attr('text-overflow', 'ellipsis')
+ .attr('overflow', 'hidden')
+ .attr('width', 25)
+ .text(d => clipString(d.data.name_given, textWidth(d)))
+
+ node
+ .append('text')
+ .filter(d => d.data.person?.profile?.birth?.date)
+ .attr('y', -boxHeight / 2 + 25 + 17 * 2)
+ .attr('x', d => -boxWidth / 2 + textPadding(d))
+ .attr('text-anchor', 'start')
+ .attr('font-weight', '350')
+ .attr('fill', 'rgba(0, 0, 0, 0.9)')
+ .attr('paint-order', 'stroke')
+ .text(d => clipString(`*${d.data.person.profile.birth.date}`, textWidth(d)))
+
+ node
+ .append('text')
+ .filter(d => d.data.person?.profile?.death?.date)
+ .attr('y', -boxHeight / 2 + 25 + 17 * 3)
+ .attr('x', d => -boxWidth / 2 + textPadding(d))
+ .attr('text-anchor', 'start')
+ .attr('font-weight', '350')
+ .attr('fill', 'rgba(0, 0, 0, 0.9)')
+
+ .attr('paint-order', 'stroke')
+ .text(d => clipString(`†${d.data.person.profile.death.date}`, textWidth(d)))
+
+ node
+ .filter(getImageUrl)
+ .append('circle')
+ .attr('r', imgRadius)
+ .attr('cy', -boxHeight / 2 + imgRadius + imgPadding)
+ .attr('cx', -boxWidth / 2 + imgRadius + imgPadding)
+ .attr('fill', d => `url(#imgpattern-${d.data.id})`)
+
+ const defs = svg.append('defs')
+
+ const imgPattern = defs
+ .selectAll('.imgpattern')
+ .data(descendants)
+ .enter()
+ .append('pattern')
+ .attr('id', d => `imgpattern-${d.data.id}`)
+ .attr('height', 1)
+ .attr('width', 1)
+ .attr('x', '0')
+ .attr('y', '0')
+
+ imgPattern
+ .append('image')
+ .attr('x', 0)
+ .attr('y', 0)
+ .attr('height', 70)
+ .attr('width', 70)
+ .attr('xlink:href', getImageUrl)
+
+ node.style('cursor', 'pointer').on('click', clicked)
+
+ return svg.node()
+}
diff --git a/src/charts/util.js b/src/charts/util.js
new file mode 100644
index 00000000..ffad7ed1
--- /dev/null
+++ b/src/charts/util.js
@@ -0,0 +1,84 @@
+// Utility functions for d3.js charts.
+
+import {getThumbnailUrl, getThumbnailUrlCropped} from '../api.js'
+
+export const getPerson = (data, handle) =>
+ data.find(person => person.handle === handle) || {}
+
+export const getPersonByGrampsId = (data, grampsId) =>
+ data.find(person => person.gramps_id === grampsId) || {}
+
+export const getImageUrl = (person, size, square = true) => {
+ if (!person.media_list || person.media_list.length === 0) {
+ return ''
+ }
+ const [mediaRef] = person.media_list
+ if (!mediaRef.rect || mediaRef.rect.length === 0) {
+ return getThumbnailUrl(mediaRef.ref, size, square)
+ }
+ return getThumbnailUrlCropped(mediaRef.ref, mediaRef.rect, size, square)
+}
+
+export const getTree = (
+ data,
+ handle,
+ depth,
+ includeEmpty = true,
+ i = 0,
+ label = 'p'
+) => {
+ if (depth === 0) {
+ return {}
+ }
+ const person = getPerson(data, handle)
+ const tree = {
+ name_given: person?.profile ? person?.profile?.name_given : null,
+ name_surname: person?.profile ? person?.profile?.name_surname : null,
+ id: label,
+ depth: i,
+ person,
+ }
+ if (depth === 1) {
+ return tree
+ }
+ const fatherHandle =
+ person?.extended?.primary_parent_family?.father_handle || ''
+ const motherHandle =
+ person?.extended?.primary_parent_family?.mother_handle || ''
+ tree.children = []
+ if (fatherHandle || includeEmpty) {
+ tree.children.push(
+ getTree(data, fatherHandle, depth - 1, includeEmpty, i + 1, `${label}f`)
+ )
+ }
+ if (motherHandle || includeEmpty) {
+ tree.children.push(
+ getTree(data, motherHandle, depth - 1, includeEmpty, i + 1, `${label}m`)
+ )
+ }
+ return tree
+}
+
+export const getDescendantTree = (data, handle, depth, i = 0, label = 'p') => {
+ if (depth === 0) {
+ return {}
+ }
+ const person = getPerson(data, handle)
+ const tree = {
+ name_given: person?.profile ? person?.profile?.name_given : null,
+ name_surname: person?.profile ? person?.profile?.name_surname : null,
+ id: label,
+ depth: i,
+ person,
+ }
+ if (depth === 1) {
+ return tree
+ }
+ const childHandles = (person?.extended?.families || [])
+ .flatMap(fam => fam.child_ref_list)
+ .map(cref => cref.ref)
+ tree.children = childHandles.map((childHandle, childInd) =>
+ getTree(data, childHandle, depth - 1, false, i + 1, `${label}c${childInd}`)
+ )
+ return tree
+}
diff --git a/src/components/GrampsjsFanChart.js b/src/components/GrampsjsFanChart.js
new file mode 100644
index 00000000..c8f418c7
--- /dev/null
+++ b/src/components/GrampsjsFanChart.js
@@ -0,0 +1,69 @@
+import {html, css, LitElement} from 'lit'
+
+import {sharedStyles} from '../SharedStyles.js'
+import {GrampsjsTranslateMixin} from '../mixins/GrampsjsTranslateMixin.js'
+import {FanChart} from '../charts/FanChart.js'
+import {getPersonByGrampsId, getTree} from '../charts/util.js'
+
+class GrampsjsFanChart extends GrampsjsTranslateMixin(LitElement) {
+ static get styles() {
+ return [
+ sharedStyles,
+ css`
+ svg a {
+ text-decoration: none !important;
+ }
+
+ div#container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ `,
+ ]
+ }
+
+ static get properties() {
+ return {
+ grampsId: {type: String},
+ depth: {type: Number},
+ data: {type: Array},
+ }
+ }
+
+ constructor() {
+ super()
+ this.grampsId = ''
+ this.depth = 5
+ this.data = []
+ }
+
+ render() {
+ if (this.data.length === 0 || !this.grampsId) {
+ return ''
+ }
+ return html`${this.renderChart()}`
+ }
+
+ renderChart() {
+ const {handle} = getPersonByGrampsId(this.data, this.grampsId)
+ if (!handle) {
+ return ''
+ }
+ const data = getTree(this.data, handle, this.depth)
+ const radius = this.depth * 60
+ const margin = 5
+ return html`
+
+ ${FanChart(data, {
+ margin,
+ radius,
+ width: 2 * radius + margin,
+ height: 2 * radius + margin,
+ })}
+
+ `
+ }
+}
+
+window.customElements.define('grampsjs-fan-chart', GrampsjsFanChart)
diff --git a/src/components/GrampsjsGraph.js b/src/components/GrampsjsGraph.js
deleted file mode 100644
index cfb9b812..00000000
--- a/src/components/GrampsjsGraph.js
+++ /dev/null
@@ -1,591 +0,0 @@
-/* eslint-disable lit-a11y/click-events-have-key-events */
-import {html, css, LitElement} from 'lit'
-
-import * as hpccWasm from '@hpcc-js/wasm'
-
-import '@material/mwc-dialog'
-import '@material/mwc-icon'
-import '@material/mwc-icon-button'
-import '@material/mwc-list/mwc-list-item'
-
-import {sharedStyles} from '../SharedStyles.js'
-import {fireEvent} from '../util.js'
-import {GrampsjsTranslateMixin} from '../mixins/GrampsjsTranslateMixin.js'
-
-// transform 2D coordinates (x, y) to SVG coordinates.
-// element can be the SVG itself or an element within it
-// (possibly transformed)
-function transformSvgCoords(svg, element, x, y) {
- const point = svg.createSVGPoint()
- point.x = x
- point.y = y
- const invertedSVGMatrix = element.getScreenCTM().inverse()
- return point.matrixTransform(invertedSVGMatrix)
-}
-
-// get a point in SVG coordinates from an event
-function getPointFromEvent(svg, event) {
- return transformSvgCoords(svg, svg, event.clientX, event.clientY)
-}
-
-const _zoomDefault = 0.7
-
-class GrampsjsGraph extends GrampsjsTranslateMixin(LitElement) {
- static get styles() {
- return [
- sharedStyles,
- css`
- :host {
- width: 100%;
- height: 100%;
- }
-
- #controls {
- z-index: 1;
- position: absolute;
- top: 85px;
- left: 15px;
- border-radius: 5px;
- background-color: rgba(255, 255, 255, 0.9);
- }
-
- #controls mwc-icon-button {
- color: rgba(0, 0, 0, 0.3);
- --mdc-icon-size: 26px;
- --mdc-theme-text-disabled-on-light: rgba(0, 0, 0, 0.1);
- }
-
- #graph {
- width: 100%;
- height: 100%;
- overflow: hidden;
- background-color: rgb(230, 230, 230);
- }
-
- #graph svg text {
- font-family: var(--grampsjs-body-font-family);
- }
-
- #graph svg {
- height: 100%;
- width: 100%;
- position: relative;
- left: 0;
- top: 0;
- touch-action: none;
- }
-
- #graph svg .edge path {
- stroke-width: 1.5px;
- stroke: #666;
- }
-
- #graph svg .edge polygon {
- fill: #666;
- stroke: #666;
- stroke-width: 0;
- }
-
- g.node polygon,
- g.node path,
- g.node ellipse {
- fill: #ffffff;
- }
-
- g.node ellipse {
- stroke: none;
- }
-
- g#node1 polygon {
- fill: #64b5f6;
- }
-
- g#node1 path {
- fill: #ef9a9a;
- }
-
- g.node text {
- font-weight: 400;
- font-size: 13px;
- }
-
- g.node text:last-of-type {
- font-weight: 300;
- font-size: 12px;
- }
-
- g.node polygon,
- g.node path,
- g.node text {
- cursor: pointer;
- }
-
- g.node polygon,
- g.node path,
- g.node ellipse {
- stroke-width: 1.5px;
- }
-
- svg {
- cursor: grab;
- }
-
- mwc-dialog mwc-icon-button {
- vertical-align: middle;
- }
- `,
- ]
- }
-
- static get properties() {
- return {
- src: {type: String},
- scale: {type: Number},
- disableBack: {type: Boolean},
- disableHome: {type: Boolean},
- nAnc: {type: Number},
- nDesc: {type: Number},
- _svg: {type: Object},
- _svgPointerDown: {type: Boolean},
- _zoomInPointerDown: {type: Boolean},
- _zoomOutPointerDown: {type: Boolean},
- _pointerOrigin: {type: Object},
- _interval: {type: Object},
- _evcache: {type: Array},
- _prevDiff: {type: Number},
- }
- }
-
- constructor() {
- super()
- this.src = ''
- this.scale = _zoomDefault
- this.disableBack = false
- this.disableHome = false
- this._svgPointerDown = false
- this._zoomInPointerDown = false
- this._zoomOutPointerDown = false
- this._evCache = []
- this._prevDiff = -1
- }
-
- render() {
- return html`
-
- ${this._renderControls()}
- `
- }
-
- _renderControls() {
- return html`
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `
- }
-
- _resetLevels() {
- fireEvent(this, 'tree:setNAnc', {data: 3})
- fireEvent(this, 'tree:setNDesc', {data: 1})
- }
-
- _increaseNAnc() {
- fireEvent(this, 'tree:increaseNAnc')
- }
-
- _decreaseNAnc() {
- fireEvent(this, 'tree:decreaseNAnc')
- }
-
- _increaseNDesc() {
- fireEvent(this, 'tree:increaseNDesc')
- }
-
- _decreaseNDesc() {
- fireEvent(this, 'tree:decreaseNDesc')
- }
-
- _backToHomePerson() {
- fireEvent(this, 'tree:home')
- }
-
- _goToPerson() {
- fireEvent(this, 'tree:person')
- }
-
- _handleBack() {
- fireEvent(this, 'tree:back')
- }
-
- _zoomIn(f) {
- this.scale *= f
- }
-
- // zoom in when mouse clicked/touch starts
- _zoomInDown() {
- this._zoomIn(1.1)
- this._zoomInPointerDown = true
- setTimeout(() => {
- if (this._zoomInPointerDown) {
- this._interval = setInterval(() => {
- if (this._zoomInPointerDown) {
- this._zoomIn(1.04)
- }
- }, 20)
- }
- }, 700)
- }
-
- // stop zooming in
- _zoomInUp() {
- this._zoomInPointerDown = false
- clearInterval(this._interval)
- }
-
- _zoomOut(f) {
- this.scale /= f
- }
-
- // zoom out when mouse clicked/touch starts
- _zoomOutDown() {
- this._zoomOut(1.1)
- this._zoomOutPointerDown = true
- setTimeout(() => {
- if (this._zoomOutPointerDown) {
- this._interval = setInterval(() => {
- if (this._zoomOutPointerDown) {
- this._zoomOut(1.04)
- }
- }, 20)
- }
- }, 700)
- }
-
- // stop zooming out
- _zoomOutUp() {
- this._zoomOutPointerDown = false
- clearInterval(this._interval)
- }
-
- _resetZoom() {
- this.scaleSvg(_zoomDefault)
- this.centerSvg()
- this.scale = _zoomDefault
- }
-
- // click: find out if it is a person node: has title and no ellipse
- _handleClick(event) {
- const g = event.target.closest('g')
- if (g !== null) {
- const title = g.querySelector('title')
- if (title.innerHTML && g.querySelector('ellipse') === null) {
- // set new person gramps ID
- return this._personSelected(title.innerHTML)
- }
- }
- return null
- }
-
- update(changed) {
- super.update(changed)
- if (changed.has('src')) {
- this._renderGraph()
- }
- }
-
- updated(changed) {
- if (changed.has('scale')) {
- if (this._svg !== undefined) {
- this.scaleSvg(this.scale)
- }
- }
- }
-
- _renderGraph() {
- hpccWasm.graphvizSync().then(graphviz => {
- const div = this.shadowRoot.getElementById('graph')
- if (div === null) {
- return
- }
- div.innerHTML = graphviz.layout(this.src, 'svg', 'dot')
- this._svg = this.shadowRoot.querySelector('svg')
- if (this._svg === null) {
- return
- }
- this._svg.querySelector('polygon[fill="white"]').style.fill = 'none'
- // use arrow functions to bind correct 'this'
- this._svg.addEventListener('pointerup', e => this._pUp(e))
- this._svg.addEventListener('pointerleave', e => this._pUp(e))
- this._svg.addEventListener('pointerdown', e => this._pDown(e))
- this._svg.addEventListener('pointermove', e => this._pMove(e))
- this._svg.addEventListener('wheel', e => this._wheel(e))
- this._svg.addEventListener('dblclick', e => this._dblclick(e))
- this.scaleSvg(this.scale)
- this.centerSvg()
- })
- }
-
- _dblclick(e) {
- this._zoomIn(1.3)
- e.preventDefault()
- e.stopPropagation()
- }
-
- // wheel zoom
- _wheel(event) {
- this.scale *= 1 - event.deltaY / 500
- }
-
- // start panning
- _pDown(event) {
- if (this._svg === undefined) {
- return
- }
- this._svgPointerDown = true
- this._pointerOrigin = getPointFromEvent(this._svg, event)
- this._svg.style.cursor = 'grabbing'
- // event cache for pinch zoom
- this._evCache.push(event)
- }
-
- _pMove(event) {
- if (!this._svgPointerDown) {
- return
- }
- // code for panning
- event.preventDefault()
- // code for pich zoom
- for (let i = 0; i < this._evCache.length; i += 1) {
- if (event.pointerId === this._evCache[i].pointerId) {
- this._evCache[i] = event
- break
- }
- }
- // If two pointers are down, check for pinch gestures
- if (this._evCache.length === 2) {
- // Calculate the distance between the two pointers
- const curDiff = Math.hypot(
- this._evCache[0].clientX - this._evCache[1].clientX,
- this._evCache[0].clientY - this._evCache[1].clientY
- )
-
- if (this._prevDiff > 0) {
- const oldScale = this.scale
- if (curDiff > this._prevDiff) {
- // The distance between the two pointers has increased
- }
- if (curDiff < this._prevDiff) {
- // The distance between the two pointers has decreased
- }
- this.scale = (oldScale * curDiff) / this._prevDiff
- }
- // Cache the distance for the next move event
- this._prevDiff = curDiff
- } else {
- // code for panning
- const pointerPosition = getPointFromEvent(this._svg, event)
- const viewBox = this._svg.viewBox.baseVal
- viewBox.x -= pointerPosition.x - this._pointerOrigin.x
- viewBox.y -= pointerPosition.y - this._pointerOrigin.y
- }
- }
-
- removeEvent(ev) {
- // Remove this event from the target's cache
- for (let i = 0; i < this._evCache.length; i += 1) {
- if (this._evCache[i].pointerId === ev.pointerId) {
- this._evCache.splice(i, 1)
- break
- }
- }
- }
-
- // stop panning
- _pUp(event) {
- this._svgPointerDown = false
- this._svg.style.cursor = 'grab'
- // Remove this pointer from the cache and reset the target's
- // background and border
- this.removeEvent(event)
- // If the number of pointers down is less than two then reset diff tracker
- if (this._evCache.length < 2) {
- this._prevDiff = -1
- }
- }
-
- scaleSvg(s) {
- const viewBox = this._svg.viewBox.baseVal
- const container = this.shadowRoot.querySelector('#graph')
- const bb = container.getBoundingClientRect()
- const pt = 4 / 3
- const wOld = viewBox.width
- const hOld = viewBox.height
- viewBox.width = bb.width / pt / s
- viewBox.height = bb.height / pt / s
- // shift to scale around viewBox center
- viewBox.x += (wOld - viewBox.width) / 2
- viewBox.y += (hOld - viewBox.height) / 2
- }
-
- centerSvg() {
- const bbox = this._svg.getBBox()
- const viewBox = this._svg.viewBox.baseVal
- viewBox.x = bbox.x + bbox.width / 2 - viewBox.width / 2
- viewBox.y = bbox.y + bbox.height / 2 - viewBox.height / 2
- }
-
- // center the element given by selector in the center of the parent
- // container
- centerSvgOn(selectorString) {
- const el = this.shadowRoot.querySelector(selectorString)
- const cont = this.shadowRoot.querySelector('#graph')
- if (el === null) {
- return
- }
- const bbTarget = el.getBoundingClientRect()
- const bbCont = cont.getBoundingClientRect()
- const xCenterPxOld = bbTarget.left + bbTarget.width / 2
- const yCenterPxOld = bbTarget.bottom + bbTarget.height / 2
- const pointOld = transformSvgCoords(
- this._svg,
- this._svg,
- xCenterPxOld,
- yCenterPxOld
- )
- const xCenterPxNew = bbCont.left + bbCont.width / 2
- const yCenterPxNew = bbCont.top + bbCont.height / 2
- const pointNew = transformSvgCoords(
- this._svg,
- this._svg,
- xCenterPxNew,
- yCenterPxNew
- )
- const dx = pointNew.x - pointOld.x
- const dy = pointNew.y - pointOld.y
- const viewBox = this._svg.viewBox.baseVal
- viewBox.x += -dx
- viewBox.y += -dy
- }
-
- _openMenuControls() {
- const menu = this.shadowRoot.getElementById('menu-controls')
- menu.open = true
- }
-
- _handleResize() {
- this.scaleSvg(this.scale)
- }
-
- connectedCallback() {
- super.connectedCallback()
- window.addEventListener('resize', () => this._handleResize())
- }
-
- disconnectedCallback() {
- window.removeEventListener('resize', () => this._handleResize())
- super.disconnectedCallback()
- }
-
- _personSelected(grampsId) {
- this.dispatchEvent(
- new CustomEvent('pedigree:person-selected', {
- bubbles: true,
- composed: true,
- detail: {grampsId},
- })
- )
- }
-}
-
-window.customElements.define('grampsjs-graph', GrampsjsGraph)
diff --git a/src/components/GrampsjsPedigree.js b/src/components/GrampsjsPedigree.js
deleted file mode 100644
index d9d334a3..00000000
--- a/src/components/GrampsjsPedigree.js
+++ /dev/null
@@ -1,226 +0,0 @@
-import {html, css, LitElement} from 'lit'
-
-import {sharedStyles} from '../SharedStyles.js'
-import './GrampsjsPedigreeCard.js'
-
-class GrampsjsPedigree extends LitElement {
- static get styles() {
- return [
- sharedStyles,
- css`
- div#container {
- position: relative;
- }
-
- div.card {
- position: absolute;
- }
-
- div.branch-right,
- div.branch-left {
- position: absolute;
- border-color: #aaa;
- border-style: solid;
- border-width: 0px;
- }
-
- div.branch-right.male {
- border-top-left-radius: 15px;
- border-left-width: 1px;
- border-top-width: 1px;
- }
-
- div.branch-right.female {
- border-bottom-left-radius: 15px;
- border-left-width: 1px;
- border-bottom-width: 1px;
- }
-
- div.branch-left.male {
- border-bottom-width: 1px;
- }
-
- div.branch-left.female {
- border-top-width: 1px;
- }
-
- div.icon svg path {
- fill: #ccc;
- }
-
- .gray {
- color: #aaa;
- }
- `,
- ]
- }
-
- static get properties() {
- return {
- grampsId: {type: String},
- people: {type: Array},
- depth: {type: Number},
- }
- }
-
- constructor() {
- super()
- this.people = []
- this.depth = 4
- }
-
- render() {
- const ancestors = this._getTree()
- const children = this._getChildren()
- return html`
-
- ${ancestors.map((g, i) =>
- i > this.depth - 1
- ? ''
- : html`
- ${g.map((p, j) =>
- Object.keys(p).length
- ? html`
-
-
this._personSelected(p)}"
- >
-
- ${i === 0
- ? ''
- : html`
-
-
- `}
-
- `
- : ''
- )}
- `
- )}
- ${children.map((p, i) =>
- Object.keys(p).length
- ? html`
-
- `
- : ''
- )}
-
- `
- }
-
- _getTree() {
- const ancestors = []
- ancestors.push([this._getPerson(this.grampsId)])
- if (this.depth === 1) {
- return ancestors
- }
- ancestors.push(this._getParents(this.grampsId))
- if (this.depth === 2) {
- return ancestors
- }
- for (let i = 3; i <= this.depth; i += 1) {
- ancestors.push(
- ancestors
- .slice(-1)[0]
- .map(p => this._getParents(p.gramps_id))
- .flat()
- )
- }
- return ancestors
- }
-
- _getPerson(grampsId) {
- return this.people.find(person => person.gramps_id === grampsId) || {}
- }
-
- _getPersonByHandle(handle) {
- return this.people.find(person => person.handle === handle) || {}
- }
-
- _getParents(grampsId) {
- const person = this._getPerson(grampsId)
- const fatherHandle =
- person?.profile?.primary_parent_family?.father?.handle || {}
- const motherHandle =
- person?.profile?.primary_parent_family?.mother?.handle || {}
- const father = this._getPersonByHandle(fatherHandle) || {}
- const mother = this._getPersonByHandle(motherHandle) || {}
- return [father, mother]
- }
-
- _getChildren() {
- const person = this._getPerson(this.grampsId)
- const families = person?.profile?.families || []
- const childHandles = families.map(family =>
- (family.children || []).map(child => child.handle)
- )
- return childHandles
- .flat()
- .filter(h => h !== undefined)
- .map(this._getPersonByHandle, this)
- }
-
- _personSelected(person) {
- this.dispatchEvent(
- new CustomEvent('pedigree:person-selected', {
- bubbles: true,
- composed: true,
- detail: {grampsId: person.gramps_id},
- })
- )
- }
-}
-
-window.customElements.define('grampsjs-pedigree', GrampsjsPedigree)
diff --git a/src/components/GrampsjsTreeChart.js b/src/components/GrampsjsTreeChart.js
new file mode 100644
index 00000000..7a1ff416
--- /dev/null
+++ b/src/components/GrampsjsTreeChart.js
@@ -0,0 +1,160 @@
+import {html, css, LitElement} from 'lit'
+
+import '@material/mwc-menu'
+import '@material/mwc-list/mwc-list-item'
+
+import {sharedStyles} from '../SharedStyles.js'
+import {GrampsjsTranslateMixin} from '../mixins/GrampsjsTranslateMixin.js'
+import {TreeChart} from '../charts/TreeChart.js'
+import {
+ getDescendantTree,
+ getPersonByGrampsId,
+ getTree,
+ getImageUrl,
+} from '../charts/util.js'
+import {fireEvent, clickKeyHandler} from '../util.js'
+
+class GrampsjsTreeChart extends GrampsjsTranslateMixin(LitElement) {
+ static get styles() {
+ return [
+ sharedStyles,
+ css`
+ svg a {
+ text-decoration: none !important;
+ }
+
+ div#container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ mwc-menu {
+ --mdc-typography-subtitle1-font-size: 13px;
+ --mdc-menu-item-height: 36px;
+ }
+ `,
+ ]
+ }
+
+ static get properties() {
+ return {
+ grampsId: {type: String},
+ depth: {type: Number},
+ data: {type: Array},
+ }
+ }
+
+ constructor() {
+ super()
+ this.grampsId = ''
+ this.depth = 5
+ this.data = []
+ }
+
+ render() {
+ if (this.data.length === 0 || !this.grampsId) {
+ return ''
+ }
+ return html`
+
+ ${this.renderChart()} ${this.renderChildrenMenu()}
+
+ `
+ }
+
+ renderChart() {
+ const {handle} = getPersonByGrampsId(this.data, this.grampsId)
+ if (!handle) {
+ return ''
+ }
+ const data = getTree(this.data, handle, this.depth, false)
+ return html`
+
+ ${TreeChart(data, {
+ depth: this.depth,
+ childrenTriangle: this._hasChildren(),
+ getImageUrl: d => getImageUrl(d?.data?.person || {}, 200),
+ })}
+
+ `
+ }
+
+ _hasChildren() {
+ const {handle} = getPersonByGrampsId(this.data, this.grampsId)
+ const data = getDescendantTree(this.data, handle, 2)
+ if (data.children && data.children.length) {
+ return true
+ }
+ return false
+ }
+
+ renderChildrenMenu() {
+ const {handle} = getPersonByGrampsId(this.data, this.grampsId)
+ const data = getDescendantTree(this.data, handle, 2)
+ const {children} = data
+ if (!children || !children.length) {
+ return ''
+ }
+ return html`
+
+ ${children.map(
+ child =>
+ html`
+ this._handleChild(child.person.gramps_id)}
+ @keydown=${clickKeyHandler}
+ >${child.name_given || html`$hellip;`}
+ `
+ )}
+
+ `
+ }
+
+ _handleChild(grampsId) {
+ fireEvent(this, 'pedigree:person-selected', {grampsId})
+ this._closeMenu()
+ }
+
+ _handleShowChildren() {
+ const triangle = this.renderRoot.querySelector('#triangle-children')
+ if (triangle !== null) {
+ this._openMenu()
+ }
+ }
+
+ _openMenu() {
+ const menu = this.renderRoot.querySelector('mwc-menu')
+ if (menu !== null) {
+ menu.open = true
+ }
+ }
+
+ _closeMenu() {
+ const menu = this.renderRoot.querySelector('mwc-menu')
+ if (menu !== null) {
+ menu.open = false
+ }
+ }
+
+ update(changed) {
+ super.update(changed)
+ if (changed.has('data')) {
+ this._updateMenuAnchor()
+ }
+ }
+
+ _updateMenuAnchor() {
+ const menu = this.renderRoot.querySelector('mwc-menu')
+ const triangle = this.renderRoot.querySelector('#triangle-children')
+ if (menu !== null && triangle !== null) {
+ menu.anchor = triangle
+ }
+ }
+}
+
+window.customElements.define('grampsjs-tree-chart', GrampsjsTreeChart)
diff --git a/src/icons.js b/src/icons.js
index d3a3a7d4..5572239b 100644
--- a/src/icons.js
+++ b/src/icons.js
@@ -60,6 +60,8 @@ export const filePdfIcon = html``
+export const chartFanIconPath =
+ 'm13 2.0742v3.0469a7 7 0 0 1 5.9434 5.8789h3.0059a10 10 0 0 0-8.9492-8.9258zm-2 0.0019531a10 10 0 0 0-8.9277 8.9238h3.0508a7 7 0 0 1 5.877-5.873v-3.0508zm1 4.5605a5.3638 5.3638 0 0 0-5.2852 4.4688h2.7754a2.6819 2.6819 0 0 1 2.5098-1.7871 2.6819 2.6819 0 0 1 2.5273 1.7871h2.7598a5.3638 5.3638 0 0 0-5.2871-4.4688zm-5.2852 6.2578a5.3638 5.3638 0 0 0 5.2852 4.4688 5.3638 5.3638 0 0 0 5.2852-4.4688h-2.7754a2.6819 2.6819 0 0 1-2.5098 1.7871 2.6819 2.6819 0 0 1-2.5098-1.7871h-2.7754zm-4.6426 0.10547a10 10 0 0 0 8.9277 8.9238v-2.9863a7 7 0 0 1-5.8828-5.9375h-3.0449zm16.877 0a7 7 0 0 1-5.9492 5.9434v2.9805a10 10 0 0 0 8.9277-8.9238h-2.9785z'
export function renderIcon(path, color = '#999999') {
return html`
+
+ `
+ }
+}
+
+window.customElements.define('grampsjs-view-fan-chart', GrampsjsViewFanChart)
diff --git a/src/views/GrampsjsViewGraph.js b/src/views/GrampsjsViewGraph.js
deleted file mode 100644
index 4e576896..00000000
--- a/src/views/GrampsjsViewGraph.js
+++ /dev/null
@@ -1,154 +0,0 @@
-import {html, css} from 'lit'
-
-import {GrampsjsView} from './GrampsjsView.js'
-import '../components/GrampsjsGraph.js'
-import {apiGet} from '../api.js'
-import {fireEvent} from '../util.js'
-
-export class GrampsjsViewGraph extends GrampsjsView {
- static get styles() {
- return [
- super.styles,
- css`
- :host {
- margin: 0;
- margin-top: -4px;
- }
-
- #outer-container {
- height: calc(100vh - 68px);
- }
- `,
- ]
- }
-
- static get properties() {
- return {
- grampsId: {type: String},
- disableBack: {type: Boolean},
- disableHome: {type: Boolean},
- nAnc: {type: Number},
- nDesc: {type: Number},
- _data: {type: Array},
- _graph: {type: String},
- }
- }
-
- constructor() {
- super()
- this.grampsId = ''
- this._data = []
- this.nAnc = 3
- this.nDesc = 1
- this.disableBack = false
- this.disableHome = false
- this._graph = ''
- }
-
- renderContent() {
- return html`
-
- this._changeNAnc(1)}
- @tree:decreaseNAnc=${() => this._changeNAnc(-1)}
- @tree:increaseNDesc=${() => this._changeNDesc(1)}
- @tree:decreaseNDesc=${() => this._changeNDesc(-1)}
- @tree:setNAnc=${this._setNAnc}
- @tree:setNDesc=${this._setNDesc}
- >
-
-
- `
- }
-
- _changeNAnc(n) {
- if (n === 0 || this.nAnc + n < 0) {
- return
- }
- this.nAnc += n
- this._fetchData(this.grampsId)
- }
-
- _setNAnc(event) {
- this.nAnc = event.detail.data
- this._fetchData(this.grampsId)
- }
-
- _changeNDesc(n) {
- if (n === 0 || this.nDesc + n < 0) {
- return
- }
- this.nDesc += n
- this._fetchData(this.grampsId)
- }
-
- _setNDesc(event) {
- this.nDesc = event.detail.data
- this._fetchData(this.grampsId)
- }
-
- _goToPerson() {
- fireEvent(this, 'nav', {path: `person/${this.grampsId}`})
- }
-
- update(changed) {
- super.update(changed)
- if (changed.has('grampsId')) {
- this._fetchData(this.grampsId)
- }
- }
-
- async _fetchData(grampsId) {
- this.loading = true
- const options = {
- off: 'dot',
- ratio: 'compress',
- papermb: '0',
- papermt: '0',
- paperml: '0',
- papermr: '0',
- pid: grampsId,
- maxascend: `${this.nAnc}`,
- maxdescend: `${this.nDesc}`,
- color: 'colored',
- colormales: '#64B5F6',
- colorfemales: '#EF9A9A',
- colorfamilies: '#000000',
- roundcorners: 'True',
- papers: 'A0',
- arrow: '',
- // ranksep: '0.3'
- }
- const data = await apiGet(
- `/api/reports/hourglass_graph/file?options=${encodeURIComponent(
- JSON.stringify(options)
- )}`,
- false
- )
- this.loading = false
- if ('data' in data) {
- this._graph = data.data.replace('()', '')
- } else if ('error' in data) {
- this.error = true
- this._errorMessage = data.error
- this._graph = ''
- }
- }
-
- _resizeHandler() {
- clearTimeout(this._resizeTimer)
- }
-
- firstUpdated() {
- window.addEventListener('resize', this._resizeHandler.bind(this))
- this._fetchData(this.grampsId)
- }
-}
-
-window.customElements.define('grampsjs-view-graph', GrampsjsViewGraph)
diff --git a/src/views/GrampsjsViewPedigree.js b/src/views/GrampsjsViewPedigree.js
deleted file mode 100644
index 8b2bc247..00000000
--- a/src/views/GrampsjsViewPedigree.js
+++ /dev/null
@@ -1,291 +0,0 @@
-import {html, css} from 'lit'
-import '@material/mwc-slider'
-import '@material/mwc-button'
-import '@material/mwc-icon'
-
-import {GrampsjsView} from './GrampsjsView.js'
-import '../components/GrampsjsPedigree.js'
-import {apiGet} from '../api.js'
-import {fireEvent} from '../util.js'
-
-export class GrampsjsViewPedigree extends GrampsjsView {
- static get styles() {
- return [
- super.styles,
- css`
- mwc-slider {
- --mdc-theme-secondary: #4fc3f7;
- }
-
- #button-block {
- float: left;
- position: relative;
- top: 10px;
- }
-
- #pedigree-container {
- clear: left;
- }
-
- mwc-button {
- --mdc-ripple-focus-opacity: 0;
- --mdc-theme-primary: rgba(0, 0, 0, 0.7);
- }
-
- #outer-container {
- clear: left;
- padding-top: 30px;
- padding-left: 30px;
- }
-
- #controls {
- z-index: 1;
- position: absolute;
- top: 85px;
- left: 15px;
- border-radius: 5px;
- background-color: rgba(255, 255, 255, 0.9);
- }
-
- #controls mwc-icon-button {
- color: rgba(0, 0, 0, 0.3);
- --mdc-icon-size: 26px;
- --mdc-theme-text-disabled-on-light: rgba(0, 0, 0, 0.1);
- }
-
- #menu-controls mwc-list-item {
- --mdc-ripple-color: transparent;
- }
-
- mwc-slider {
- padding: 15px;
- padding-top: 50px;
- }
-
- mwc-list-item.slider {
- --mdc-menu-item-height: 70px;
- }
- `,
- ]
- }
-
- static get properties() {
- return {
- grampsId: {type: String},
- disableBack: {type: Boolean},
- disableHome: {type: Boolean},
- _data: {type: Array},
- _depth: {type: Number},
- _zoom: {type: Number},
- _history: {type: Array},
- }
- }
-
- constructor() {
- super()
- this.grampsId = ''
- this.disableBack = false
- this.disableHome = false
- this._data = []
- this._depth = 3
- this._zoom = 1
- this._history = []
- }
-
- renderContent() {
- if (this._data.length === 0) {
- return html` ${this._renderControls()} `
- }
- return html`
-
-
-
- ${this._renderControls()}
-
- `
- }
-
- _renderControls() {
- return html` `
- }
-
- _openMenuControls() {
- const menu = this.shadowRoot.getElementById('menu-controls')
- menu.open = true
- }
-
- _backToHomePerson() {
- fireEvent(this, 'tree:home')
- }
-
- _prevPerson() {
- fireEvent(this, 'tree:back')
- }
-
- update(changed) {
- super.update(changed)
- if (changed.has('grampsId')) {
- this._fetchData(this.grampsId)
- // this._history.push(this.grampsId)
- // limit history to 100 people
- // this._history = this._history.slice(-100)
- }
- if (changed.has('_depth')) {
- this.setZoom()
- this._fetchData(this.grampsId)
- }
- if (changed.has('active')) {
- this.setZoom()
- const slider = this.shadowRoot.getElementById('slider')
- if (slider) {
- slider.layout()
- }
- }
- }
-
- async _fetchData(grampsId) {
- this.loading = true
- const rules = {
- function: 'or',
- rules: [
- {
- name: 'IsLessThanNthGenerationAncestorOf',
- values: [grampsId, this._depth || 1],
- },
- {
- name: 'IsLessThanNthGenerationDescendantOf',
- values: [grampsId, 1],
- },
- ],
- }
- const data = await apiGet(
- `/api/people/?rules=${encodeURIComponent(JSON.stringify(rules))}&locale=${
- this.strings?.__lang__ || 'en'
- }&profile=self,families`
- )
- this.loading = false
- if ('data' in data) {
- this.error = false
- this._data = data.data
- } else if ('error' in data) {
- this.error = true
- this._errorMessage = data.error
- }
- }
-
- _increaseDepth() {
- this._depth += 1
- }
-
- _decreaseDepth() {
- this._depth -= 1
- }
-
- _resetLevels() {
- this._depth = 3
- }
-
- getZoom() {
- const sec = this.shadowRoot.getElementById('pedigree-section')
- if (sec === null) {
- return 1
- }
- const secWidth = sec.offsetWidth
- const treeWidth = this._depth * 230 * this._zoom
- const newZoom = ((secWidth - 24) / treeWidth) * this._zoom
- if (newZoom > 1) {
- return 1
- }
- if (newZoom < 0.2) {
- return 0.2
- }
- return newZoom
- }
-
- setZoom() {
- this._zoom = this.getZoom()
- }
-
- _resizeHandler() {
- clearTimeout(this._resizeTimer)
- this._resizeTimer = setTimeout(this.setZoom.bind(this), 250)
- }
-
- firstUpdated() {
- window.addEventListener('resize', this._resizeHandler.bind(this))
- // window.addEventListener('pedigree:person-selected', this._selectPerson.bind(this))
- this.setZoom()
- this._fetchData(this.grampsId)
- const btn = this.shadowRoot.getElementById('btn-controls')
- const menu = this.shadowRoot.getElementById('menu-controls')
- menu.anchor = btn
- }
-
- async _selectPerson(event) {
- const {grampsId} = event.detail
- await this._fetchData(grampsId)
- this.grampsId = grampsId
- }
-}
-
-window.customElements.define('grampsjs-view-pedigree', GrampsjsViewPedigree)
diff --git a/src/views/GrampsjsViewTree.js b/src/views/GrampsjsViewTree.js
index f2bc5843..c699eef6 100644
--- a/src/views/GrampsjsViewTree.js
+++ b/src/views/GrampsjsViewTree.js
@@ -2,20 +2,23 @@ import {css, html} from 'lit'
import '@material/mwc-icon-button'
import '@material/mwc-icon'
+import '@material/mwc-tab-bar'
+import '@material/mwc-tab'
+import {mdiFamilyTree} from '@mdi/js'
import {GrampsjsView} from './GrampsjsView.js'
-import './GrampsjsViewGraph.js'
-import './GrampsjsViewPedigree.js'
+import './GrampsjsViewTreeChart.js'
+import './GrampsjsViewFanChart.js'
import {fireEvent} from '../util.js'
+import {chartFanIconPath, renderIconSvg} from '../icons.js'
export class GrampsjsViewTree extends GrampsjsView {
static get styles() {
return [
super.styles,
css`
- :host {
- margin: 0;
- margin-top: -4px;
+ mwc-tab-bar {
+ margin-bottom: 20px;
}
.with-margin {
@@ -37,6 +40,14 @@ export class GrampsjsViewTree extends GrampsjsView {
--mdc-theme-text-disabled-on-light: #666;
--mdc-icon-size: 32px;
}
+
+ mwc-tab {
+ opacity: 0.8;
+ }
+
+ mwc-tab[active] {
+ opacity: 1;
+ }
`,
]
}
@@ -46,14 +57,16 @@ export class GrampsjsViewTree extends GrampsjsView {
grampsId: {type: String},
view: {type: String},
_history: {type: Array},
+ _currentTabId: {type: Number},
}
}
constructor() {
super()
this.grampsId = ''
- this.view = 'pedigree'
+ this.view = 'ancestor'
this._history = this.grampsId ? [this.grampsId] : []
+ this._currentTabId = 0
}
renderContent() {
@@ -68,37 +81,49 @@ export class GrampsjsViewTree extends GrampsjsView {
`
}
return html`
- ${this.view === 'pedigree' ? this._renderPedigree() : ''}
- ${this.view === 'graph' ? this._renderGraph() : ''}
- ${this._renderSelect()}
+ ${this.renderTabs()}
+ ${this._currentTabId === 0 ? this._renderPedigree() : ''}
+ ${this._currentTabId === 1 ? this._renderFan() : ''}
`
}
- _renderSelect() {
+ renderTabs() {
return html`
-
-
+ {
- this.view = 'pedigree'
+ this._currentTabId = 0
}}
- icon="text_rotation_none"
- style="margin-left: -5px;"
- >
- ${renderIconSvg(mdiFamilyTree, 'var(--mdc-theme-primary)')}
+
+ {
- this.view = 'graph'
+ this._currentTabId = 1
}}
- icon="text_rotate_vertical"
- >
-
+ hasImageIcon
+ label="${this._('Fan Chart')}"
+ >${renderIconSvg(
+ chartFanIconPath,
+ 'var(--mdc-theme-primary)'
+ )}
+
+
`
}
- _renderPedigree() {
+ _renderFan() {
return html`
-
-
+
`
}
- _renderGraph() {
+ _renderPedigree() {
return html`
-
-
+
`
}
- _handleSelect(event) {
- if (event.detail.index === 0) {
- this.view = 'pedigree'
- } else if (event.detail.index === 1) {
- this.view = 'graph'
- }
- }
-
_prevPerson() {
this._history.pop()
this.grampsId = this._history.pop()
@@ -172,6 +189,14 @@ export class GrampsjsViewTree extends GrampsjsView {
const {grampsId} = event.detail
this.grampsId = grampsId
}
+
+ _handleTabActivated(event) {
+ this._currentTabId = event.detail.index
+ }
+
+ _handleTabInteracted(event) {
+ this._currentTab = event.detail.tabId
+ }
}
window.customElements.define('grampsjs-view-tree', GrampsjsViewTree)
diff --git a/src/views/GrampsjsViewTreeChart.js b/src/views/GrampsjsViewTreeChart.js
new file mode 100644
index 00000000..19a38d69
--- /dev/null
+++ b/src/views/GrampsjsViewTreeChart.js
@@ -0,0 +1,31 @@
+import {html} from 'lit'
+
+import {GrampsjsViewTreeChartBase} from './GrampsjsViewTreeChartBase.js'
+import '../components/GrampsjsTreeChart.js'
+
+export class GrampsjsViewTreeChart extends GrampsjsViewTreeChartBase {
+ constructor() {
+ super()
+ this.nAnc = 3
+ this.nDesc = 1
+ this._setDesc = false
+ }
+
+ _resetLevels() {
+ this.nAnc = 3
+ }
+
+ renderChart() {
+ return html`
+
+
+ `
+ }
+}
+
+window.customElements.define('grampsjs-view-tree-chart', GrampsjsViewTreeChart)
diff --git a/src/views/GrampsjsViewTreeChartBase.js b/src/views/GrampsjsViewTreeChartBase.js
new file mode 100644
index 00000000..bf1e9527
--- /dev/null
+++ b/src/views/GrampsjsViewTreeChartBase.js
@@ -0,0 +1,216 @@
+import {css, html} from 'lit'
+
+import '@material/mwc-textfield'
+
+import {mdiAccountDetails, mdiHomeAccount} from '@mdi/js'
+import {GrampsjsView} from './GrampsjsView.js'
+import {apiGet} from '../api.js'
+import {fireEvent} from '../util.js'
+import {renderIcon} from '../icons.js'
+
+export class GrampsjsViewTreeChartBase extends GrampsjsView {
+ static get styles() {
+ return [
+ super.styles,
+ css`
+ :host {
+ margin: 0;
+ margin-top: -4px;
+ }
+
+ #controls mwc-icon-button {
+ color: rgba(0, 0, 0, 0.35);
+ --mdc-icon-size: 26px;
+ --mdc-theme-text-disabled-on-light: rgba(0, 0, 0, 0.1);
+ }
+
+ #menu-controls mwc-textfield {
+ width: 4em;
+ }
+ `,
+ ]
+ }
+
+ static get properties() {
+ return {
+ grampsId: {type: String},
+ disableBack: {type: Boolean},
+ disableHome: {type: Boolean},
+ nAnc: {type: Number},
+ nDesc: {type: Number},
+ _data: {type: Array},
+ _setAnc: {type: Boolean},
+ _setDesc: {type: Boolean},
+ }
+ }
+
+ constructor() {
+ super()
+ this.grampsId = ''
+ this.nAnc = 3
+ this.nDesc = 1
+ this.disableBack = false
+ this.disableHome = false
+ this._data = []
+ this._setAnc = true
+ this._setDesc = true
+ }
+
+ renderContent() {
+ return html`
+
+
${this.renderControls()}
+
${this.renderChart()}
+
+ `
+ }
+
+ renderControls() {
+ return html`
+ ${renderIcon(
+ mdiHomeAccount,
+ this.disableHome ? 'var(--mdc-theme-text-disabled-on-light)' : ''
+ )}
+
+ ${renderIcon(mdiAccountDetails)}
+
+
+
+
+ `
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ renderChart() {
+ return ''
+ }
+
+ _backToHomePerson() {
+ fireEvent(this, 'tree:home')
+ }
+
+ _prevPerson() {
+ fireEvent(this, 'tree:back')
+ }
+
+ update(changed) {
+ super.update(changed)
+ if (
+ changed.has('grampsId') ||
+ changed.has('nAnc') ||
+ changed.has('nDesc')
+ ) {
+ this._fetchData(this.grampsId)
+ }
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ _resetLevels() {}
+
+ _getPersonRules(grampsId) {
+ return {
+ function: 'or',
+ rules: [
+ {
+ name: 'IsLessThanNthGenerationAncestorOf',
+ values: [grampsId, this.nAnc + 1],
+ },
+ {
+ name: 'IsLessThanNthGenerationDescendantOf',
+ values: [grampsId, this.nDesc + 1],
+ },
+ ],
+ }
+ }
+
+ async _fetchData(grampsId) {
+ this.loading = true
+ const rules = this._getPersonRules(grampsId)
+ const data = await apiGet(
+ `/api/people/?rules=${encodeURIComponent(JSON.stringify(rules))}&locale=${
+ this.strings?.__lang__ || 'en'
+ }&profile=self&extend=primary_parent_family,family_list`
+ )
+ this.loading = false
+ if ('data' in data) {
+ this.error = false
+ this._data = data.data
+ } else if ('error' in data) {
+ this.error = true
+ this._errorMessage = data.error
+ }
+ }
+
+ _goToPerson() {
+ fireEvent(this, 'tree:person')
+ }
+
+ _handleBack() {
+ fireEvent(this, 'tree:back')
+ }
+
+ _handleChangeAnc(e) {
+ this.nAnc = parseInt(e.target.value, 10)
+ }
+
+ _handleChangeDesc(e) {
+ this.nDesc = parseInt(e.target.value, 10)
+ }
+
+ _openMenuControls() {
+ const menu = this.shadowRoot.getElementById('menu-controls')
+ menu.open = true
+ }
+}