Add simple tree graph mode (#4)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Add a new kind of family tree: simple tree Reviewed-on: #4
This commit is contained in:
parent
635fb667e1
commit
8086c1b4c9
14
geneit_app/package-lock.json
generated
14
geneit_app/package-lock.json
generated
@ -35,6 +35,7 @@
|
||||
"react-easy-crop": "^5.0.0",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-zoom-pan-pinch": "^3.1.0",
|
||||
"svg2pdf.js": "^2.2.2",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
@ -13603,6 +13604,19 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.1.0.tgz",
|
||||
"integrity": "sha512-a3LlP8QPgTikvteCNkZ3X6wIWC0lrg1geP5WkUJyx2MXXAhHQek3r17N1nT/esOiWGuPIECnsd9AGoK8jOeGcg==",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
@ -30,6 +30,7 @@
|
||||
"react-easy-crop": "^5.0.0",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-zoom-pan-pinch": "^3.1.0",
|
||||
"svg2pdf.js": "^2.2.2",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
|
@ -107,12 +107,16 @@ export class Member implements MemberDataApi {
|
||||
});
|
||||
}
|
||||
|
||||
get lastNameUpperCase(): string | undefined {
|
||||
return this.last_name?.toUpperCase();
|
||||
}
|
||||
|
||||
get fullName(): string {
|
||||
const firstName = this.first_name ?? "";
|
||||
|
||||
return firstName.length === 0
|
||||
? this.last_name ?? ""
|
||||
: `${firstName} ${this.last_name ?? ""}`;
|
||||
: `${firstName} ${this.last_name?.toUpperCase() ?? ""}`;
|
||||
}
|
||||
|
||||
get invertedFullName(): string {
|
||||
@ -159,6 +163,13 @@ export class Member implements MemberDataApi {
|
||||
};
|
||||
}
|
||||
|
||||
get displayBirthDeath(): string {
|
||||
let birthDeath = [];
|
||||
if (this.dateOfBirth) birthDeath.push(fmtDate(this.dateOfBirth));
|
||||
if (this.dateOfDeath) birthDeath.push(fmtDate(this.dateOfDeath));
|
||||
return birthDeath.join(" - ");
|
||||
}
|
||||
|
||||
get hasContactInfo(): boolean {
|
||||
return this.email ||
|
||||
this.phone ||
|
||||
|
@ -27,9 +27,11 @@ import { BasicFamilyTree } from "../../widgets/BasicFamilyTree";
|
||||
import { MemberItem } from "../../widgets/MemberItem";
|
||||
import { RouterLink } from "../../widgets/RouterLink";
|
||||
import { ComplexFamilyTree } from "../../widgets/complex_family_tree/ComplexFamilyTree";
|
||||
import { SimpleFamilyTree } from "../../widgets/simple_family_tree/SimpleFamilyTree";
|
||||
|
||||
enum CurrTab {
|
||||
BasicTree,
|
||||
SimpleTree,
|
||||
AdvancedTree,
|
||||
}
|
||||
|
||||
@ -43,7 +45,7 @@ export function FamilyMemberTreeRoute(): React.ReactElement {
|
||||
|
||||
const family = useFamily();
|
||||
|
||||
const [currTab, setCurrTab] = React.useState(CurrTab.AdvancedTree);
|
||||
const [currTab, setCurrTab] = React.useState(CurrTab.SimpleTree);
|
||||
const [currMode, setCurrMode] = React.useState(TreeMode.Descending);
|
||||
|
||||
const member = family.members.get(Number(memberId));
|
||||
@ -146,14 +148,17 @@ export function FamilyMemberTreeRoute(): React.ReactElement {
|
||||
aria-label="basic tabs example"
|
||||
>
|
||||
<Tab tabIndex={CurrTab.BasicTree} label="Basique" />
|
||||
<Tab tabIndex={CurrTab.SimpleTree} label="Simple" />
|
||||
<Tab tabIndex={CurrTab.AdvancedTree} label="Avancé" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* the tree itself */}
|
||||
<Paper style={{ flex: "1" }}>
|
||||
<Paper style={{ flex: "1", display: "flex", flexDirection: "column" }}>
|
||||
{currTab === CurrTab.BasicTree ? (
|
||||
<BasicFamilyTree tree={tree!} depth={currDepth} />
|
||||
) : currTab === CurrTab.SimpleTree ? (
|
||||
<SimpleFamilyTree tree={tree!} depth={currDepth} />
|
||||
) : (
|
||||
<ComplexFamilyTree
|
||||
tree={tree!}
|
||||
|
30
geneit_app/src/utils/render_utils.ts
Normal file
30
geneit_app/src/utils/render_utils.ts
Normal file
@ -0,0 +1,30 @@
|
||||
let canvas: HTMLCanvasElement = document.createElement("canvas");
|
||||
let charLen: Map<string, Map<string, number>> = new Map();
|
||||
|
||||
/**
|
||||
* Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
|
||||
*
|
||||
* @param {String} text The text to be rendered.
|
||||
* @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
|
||||
*
|
||||
* @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
|
||||
*/
|
||||
function computeTextWidth(text: string, font: string) {
|
||||
// re-use canvas object for better performance
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.font = font;
|
||||
const metrics = context.measureText(text);
|
||||
return metrics.width;
|
||||
}
|
||||
|
||||
export function getTextWidth(text: string, font: string) {
|
||||
if (!charLen.has(font)) charLen.set(font, new Map());
|
||||
const ref = charLen.get(font)!;
|
||||
|
||||
let size = 0;
|
||||
for (const c of text) {
|
||||
if (!ref.has(c)) ref.set(c, computeTextWidth(c, font));
|
||||
size += ref.get(c)!;
|
||||
}
|
||||
return size;
|
||||
}
|
File diff suppressed because one or more lines are too long
419
geneit_app/src/widgets/simple_family_tree/SimpleFamilyTree.tsx
Normal file
419
geneit_app/src/widgets/simple_family_tree/SimpleFamilyTree.tsx
Normal file
@ -0,0 +1,419 @@
|
||||
import { mdiXml } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import jsPDF from "jspdf";
|
||||
import React from "react";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { Couple } from "../../api/CoupleApi";
|
||||
import { Member } from "../../api/MemberApi";
|
||||
import { useDarkTheme } from "../../hooks/context_providers/DarkThemeProvider";
|
||||
import { FamilyTreeNode } from "../../utils/family_tree";
|
||||
import { downloadBlob } from "../../utils/files_utils";
|
||||
import { getTextWidth } from "../../utils/render_utils";
|
||||
import "./simpletree.css";
|
||||
import "./Roboto-normal";
|
||||
|
||||
const FACE_WIDTH = 60;
|
||||
const FACE_HEIGHT = 70;
|
||||
|
||||
/**
|
||||
* Vertical space between faces and text
|
||||
*/
|
||||
const FACE_TEXT_SPACING = 2;
|
||||
|
||||
/**
|
||||
* Cards height
|
||||
*/
|
||||
const CARD_HEIGHT = 114;
|
||||
|
||||
/**
|
||||
* Space between spouse cards
|
||||
*/
|
||||
const SPOUSE_SPACING = 10;
|
||||
|
||||
/**
|
||||
* Space between two siblings hierachy
|
||||
*/
|
||||
const SIBLINGS_SPACING = 0;
|
||||
|
||||
/**
|
||||
* Vertical space between two generations
|
||||
*/
|
||||
const LEVEL_SPACING = 25;
|
||||
|
||||
const NAME_FONT = "13px Roboto";
|
||||
const BIRTH_FONT = "10px Roboto";
|
||||
|
||||
interface SimpleTreeSpouseInfo {
|
||||
member: Member;
|
||||
couple: Couple;
|
||||
}
|
||||
|
||||
interface SimpleTreeNode {
|
||||
member: Member;
|
||||
spouse?: SimpleTreeSpouseInfo;
|
||||
down: SimpleTreeNode[];
|
||||
|
||||
/**
|
||||
* The width of the parent and its children
|
||||
*/
|
||||
width: number;
|
||||
|
||||
/**
|
||||
* The width of the parents
|
||||
*/
|
||||
parentWidth: number;
|
||||
|
||||
/**
|
||||
* The sum of the width of the children
|
||||
*/
|
||||
childrenWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the width of a member card
|
||||
*/
|
||||
function memberCardWidth(m?: Member): number {
|
||||
if (!m) return 0;
|
||||
const nameWidth = getTextWidth(m.fullName, NAME_FONT);
|
||||
const birthDeathWidth = getTextWidth(m.displayBirthDeath, BIRTH_FONT);
|
||||
return Math.max(FACE_WIDTH, nameWidth, birthDeathWidth);
|
||||
}
|
||||
|
||||
function center(container_width: number, el_width: number): number {
|
||||
return Math.floor((container_width - el_width) / 2);
|
||||
}
|
||||
|
||||
function buildSimpleTreeNode(
|
||||
tree: FamilyTreeNode,
|
||||
depth: number
|
||||
): SimpleTreeNode {
|
||||
if (depth === 0) throw new Error("Too much recursion reached!");
|
||||
|
||||
const lastCouple = tree.couples?.[tree.couples?.length - 1 ?? 0];
|
||||
|
||||
// Preprocess children
|
||||
let childrenToProcess = tree.down;
|
||||
if (depth > 1)
|
||||
tree.couples?.forEach(
|
||||
(c) => (childrenToProcess = childrenToProcess?.concat(c.down))
|
||||
);
|
||||
else childrenToProcess = [];
|
||||
|
||||
const node: SimpleTreeNode = {
|
||||
down:
|
||||
childrenToProcess?.map((c) => buildSimpleTreeNode(c, depth - 1)) ?? [],
|
||||
member: tree.member,
|
||||
spouse: lastCouple
|
||||
? {
|
||||
couple: lastCouple.couple,
|
||||
member: lastCouple.member,
|
||||
}
|
||||
: undefined,
|
||||
parentWidth: -1,
|
||||
childrenWidth: -1,
|
||||
width: -1,
|
||||
};
|
||||
|
||||
// Compute current level width
|
||||
let levelWidth: number;
|
||||
if (node.spouse) {
|
||||
levelWidth =
|
||||
SPOUSE_SPACING +
|
||||
memberCardWidth(node.member) +
|
||||
memberCardWidth(node.spouse.member);
|
||||
} else {
|
||||
levelWidth = memberCardWidth(node.member);
|
||||
}
|
||||
|
||||
// Compute down level width
|
||||
const downWidth =
|
||||
SIBLINGS_SPACING * node.down.length -
|
||||
SIBLINGS_SPACING +
|
||||
node.down.reduce((prev, n) => prev + n.width, 0);
|
||||
|
||||
node.parentWidth = levelWidth;
|
||||
node.childrenWidth = downWidth;
|
||||
node.width = Math.max(levelWidth, downWidth);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple family tree
|
||||
*
|
||||
* Only one couple can be shown in this version
|
||||
*/
|
||||
|
||||
export function SimpleFamilyTree(p: {
|
||||
tree: FamilyTreeNode;
|
||||
depth: number;
|
||||
}): React.ReactElement {
|
||||
const darkTheme = useDarkTheme();
|
||||
|
||||
const tree = React.useMemo(
|
||||
() => buildSimpleTreeNode(p.tree, p.depth),
|
||||
[p.tree, p.depth]
|
||||
);
|
||||
|
||||
const height = p.depth * (CARD_HEIGHT + LEVEL_SPACING) - LEVEL_SPACING;
|
||||
|
||||
console.info(`tree width=${tree.width} height=${height}`);
|
||||
|
||||
const doExport = async (onlySVG: boolean) => {
|
||||
const el = document.querySelector(".simpletree")!;
|
||||
const svg = el.outerHTML;
|
||||
|
||||
// Download in SVG format
|
||||
if (onlySVG) {
|
||||
const blob = new Blob([svg], {
|
||||
type: "image/svg+xml",
|
||||
});
|
||||
|
||||
downloadBlob(blob, "ArbreGenealogique.svg");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const PDF_MARGIN = 10;
|
||||
|
||||
// Download in PDF format
|
||||
const doc = new jsPDF({
|
||||
orientation: "l",
|
||||
format: [height + PDF_MARGIN * 2, tree.width + PDF_MARGIN * 2],
|
||||
});
|
||||
|
||||
doc.setFont("Roboto", "normal");
|
||||
|
||||
await doc.svg(el, {
|
||||
x: PDF_MARGIN,
|
||||
y: PDF_MARGIN,
|
||||
height: height,
|
||||
width: tree.width,
|
||||
});
|
||||
|
||||
// Save the created pdf
|
||||
doc.save("ArbreGenealogique.pdf");
|
||||
};
|
||||
|
||||
const exportPDF = () => doExport(false);
|
||||
|
||||
const exportSVG = () => doExport(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Tooltip title="Exporter le graphique au format PDF">
|
||||
<IconButton onClick={exportPDF}>
|
||||
<PictureAsPdfIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Exporter le graphique au format SVG">
|
||||
<IconButton onClick={exportSVG}>
|
||||
<Icon path={mdiXml} size={1} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TransformWrapper maxScale={15} minScale={0.2}>
|
||||
<TransformComponent wrapperStyle={{ width: "100%", flex: "1" }}>
|
||||
<svg
|
||||
className={`simpletree ${
|
||||
darkTheme.enabled ? "simpletree-dark" : ""
|
||||
}`}
|
||||
width={tree.width}
|
||||
height={height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<NodeArea node={tree} x={0} y={0} />
|
||||
</svg>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeArea(p: {
|
||||
x: number;
|
||||
y: number;
|
||||
childrenLinkDestX?: number;
|
||||
childrenLinkDestY?: number;
|
||||
node: SimpleTreeNode;
|
||||
}): React.ReactElement {
|
||||
let parent_x_offset: number;
|
||||
|
||||
parent_x_offset = p.x + center(p.node.width, p.node.parentWidth);
|
||||
|
||||
let unusedChildrenWidth = p.node.width - p.node.childrenWidth;
|
||||
let downXOffset = p.x + Math.floor(unusedChildrenWidth / 2);
|
||||
|
||||
let endFirstFaceX =
|
||||
parent_x_offset +
|
||||
Math.floor((memberCardWidth(p.node.member) - FACE_WIDTH) / 2) +
|
||||
FACE_WIDTH;
|
||||
|
||||
let beginingOfSecondCardX =
|
||||
parent_x_offset +
|
||||
p.node.parentWidth -
|
||||
memberCardWidth(p.node.spouse?.member);
|
||||
|
||||
let beginSecondFaceX =
|
||||
p.node.spouse &&
|
||||
beginingOfSecondCardX +
|
||||
(memberCardWidth(p.node.spouse.member) - FACE_WIDTH) / 2;
|
||||
|
||||
let middleParentFaceY = p.y + Math.floor(FACE_HEIGHT / 2);
|
||||
|
||||
// Compute points for link between children and parent
|
||||
let parentLinkX =
|
||||
parent_x_offset + Math.floor(memberCardWidth(p.node.member) / 2);
|
||||
let parentLinkY = p.y;
|
||||
|
||||
// Remove ugly little shifts
|
||||
if (Math.abs(parentLinkX - (p.childrenLinkDestX ?? 0)) < 10)
|
||||
parentLinkX = p.childrenLinkDestX!;
|
||||
|
||||
let childrenLinkX: number;
|
||||
let childrenLinkY: number;
|
||||
|
||||
if (p.node.spouse) {
|
||||
childrenLinkX = Math.floor((endFirstFaceX + beginSecondFaceX!) / 2);
|
||||
childrenLinkY = middleParentFaceY;
|
||||
} else {
|
||||
childrenLinkX =
|
||||
parent_x_offset + Math.floor(memberCardWidth(p.node.member) / 2);
|
||||
childrenLinkY = p.y + CARD_HEIGHT + 2;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Parent link */}
|
||||
{p.childrenLinkDestX && (
|
||||
<path
|
||||
className="link"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
d={`M${p.childrenLinkDestX} ${p.childrenLinkDestY} V ${
|
||||
parentLinkY - Math.floor(LEVEL_SPACING / 2)
|
||||
} H${parentLinkX} V${parentLinkY}`}
|
||||
></path>
|
||||
)}
|
||||
|
||||
<MemberCard x={parent_x_offset} y={p.y} member={p.node.member} />
|
||||
|
||||
{p.node.spouse && (
|
||||
<>
|
||||
{/* Couple link */}
|
||||
<path
|
||||
className="link"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
d={`M${endFirstFaceX} ${middleParentFaceY} H ${beginSecondFaceX}`}
|
||||
></path>
|
||||
|
||||
<MemberCard
|
||||
x={beginingOfSecondCardX}
|
||||
y={p.y}
|
||||
member={p.node.spouse.member}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{p.node.down.map((n) => {
|
||||
const el = (
|
||||
<NodeArea
|
||||
x={downXOffset}
|
||||
y={p.y + CARD_HEIGHT + LEVEL_SPACING}
|
||||
childrenLinkDestX={childrenLinkX}
|
||||
childrenLinkDestY={childrenLinkY}
|
||||
node={n}
|
||||
/>
|
||||
);
|
||||
downXOffset += n.width + SIBLINGS_SPACING;
|
||||
return el;
|
||||
})}
|
||||
|
||||
{/*<circle cx={childrenLinkX} cy={childrenLinkY} r="2" fill="red" />
|
||||
<circle cx={parentLinkX} cy={parentLinkY} r="2" fill="green" />*/}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCard(p: {
|
||||
x: number;
|
||||
y: number;
|
||||
member: Member;
|
||||
}): React.ReactElement {
|
||||
const w = memberCardWidth(p.member);
|
||||
return (
|
||||
<g transform={`translate(${p.x} ${p.y})`} width={w} height={CARD_HEIGHT}>
|
||||
{/* Member image */}
|
||||
{p.member.hasPhoto ? (
|
||||
<image
|
||||
x={center(w, FACE_WIDTH)}
|
||||
href={p.member.thumbnailURL!}
|
||||
height={FACE_HEIGHT}
|
||||
width={FACE_WIDTH}
|
||||
preserveAspectRatio="xMidYMin slice"
|
||||
></image>
|
||||
) : (
|
||||
<GenderlessIcon
|
||||
x={center(w, FACE_WIDTH)}
|
||||
width={FACE_WIDTH}
|
||||
height={FACE_HEIGHT}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Member text */}
|
||||
<text y={FACE_HEIGHT + FACE_TEXT_SPACING}>
|
||||
<tspan
|
||||
x={center(w, getTextWidth(p.member.first_name ?? "", NAME_FONT))}
|
||||
dy="14"
|
||||
font-size="13"
|
||||
fontFamily="Roboto"
|
||||
>
|
||||
{p.member.first_name ?? ""}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={center(
|
||||
w,
|
||||
getTextWidth(p.member.lastNameUpperCase ?? "", NAME_FONT)
|
||||
)}
|
||||
dy="14"
|
||||
font-size="13"
|
||||
fontFamily="Roboto"
|
||||
>
|
||||
{p.member.lastNameUpperCase ?? ""}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={center(w, getTextWidth(p.member.displayBirthDeath, BIRTH_FONT))}
|
||||
dy="14"
|
||||
font-size="10"
|
||||
fontFamily="Roboto"
|
||||
>
|
||||
{p.member.displayBirthDeath}
|
||||
</tspan>
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function GenderlessIcon(p: {
|
||||
x?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<g transform={`translate(${p.x ?? 0} 0)`}>
|
||||
<rect height={p.height} width={p.width} fill="rgb(59, 85, 96)" />
|
||||
<g transform={`scale(${p.width * 0.001616})`}>
|
||||
<path
|
||||
transform="translate(50,40)"
|
||||
fill="lightgrey"
|
||||
d="M256 288c79.5 0 144-64.5 144-144S335.5 0 256 0 112
|
||||
64.5 112 144s64.5 144 144 144zm128 32h-55.1c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16H128C57.3 320 0 377.3
|
||||
0 448v16c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48v-16c0-70.7-57.3-128-128-128z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
7
geneit_app/src/widgets/simple_family_tree/simpletree.css
Normal file
7
geneit_app/src/widgets/simple_family_tree/simpletree.css
Normal file
@ -0,0 +1,7 @@
|
||||
.simpletree-dark path {
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
.simpletree-dark tspan {
|
||||
fill: white;
|
||||
}
|
Loading…
Reference in New Issue
Block a user