464 lines
12 KiB
TypeScript
464 lines
12 KiB
TypeScript
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 = 15;
|
|
|
|
/**
|
|
* 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 firstNameWidth = getTextWidth(m.first_name ?? "", NAME_FONT);
|
|
const lastNameWidth = getTextWidth(m.lastNameUpperCase ?? "", NAME_FONT);
|
|
const birthDeathWidth = getTextWidth(m.displayBirthDeath, BIRTH_FONT);
|
|
return Math.max(FACE_WIDTH, firstNameWidth, lastNameWidth, 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: HTMLElement = 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;
|
|
|
|
const PDF_MAX_SIZE = 14400 - PDF_MARGIN * 2;
|
|
|
|
const tree_width = Math.min(tree.width, PDF_MAX_SIZE);
|
|
const tree_height = Math.min(height, PDF_MAX_SIZE);
|
|
|
|
console.info(`pdf tree w=${tree_width} h=${tree_height}`);
|
|
|
|
// Download in PDF format
|
|
const doc = new jsPDF({
|
|
orientation: "l",
|
|
unit: "px",
|
|
format: [tree_height + PDF_MARGIN * 2, tree_width + PDF_MARGIN * 2],
|
|
});
|
|
|
|
doc.setFont("Roboto", "normal");
|
|
|
|
await doc.svg(el, {
|
|
x: PDF_MARGIN,
|
|
y: PDF_MARGIN,
|
|
height: tree_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;
|
|
|
|
let pers1 = p.node.member;
|
|
let pers2 = p.node.spouse?.member;
|
|
let didSwap = false;
|
|
|
|
// Show male of the left (all the time)
|
|
if (pers2?.sex === "M") {
|
|
let s = pers1;
|
|
pers1 = pers2;
|
|
pers2 = s;
|
|
didSwap = true;
|
|
}
|
|
|
|
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(pers1) - FACE_WIDTH) / 2) +
|
|
FACE_WIDTH;
|
|
|
|
let beginingOfSecondCardX =
|
|
parent_x_offset + p.node.parentWidth - memberCardWidth(pers2);
|
|
|
|
let beginSecondFaceX =
|
|
p.node.spouse &&
|
|
beginingOfSecondCardX + (memberCardWidth(pers2) - FACE_WIDTH) / 2;
|
|
|
|
let middleParentFaceY = p.y + Math.floor(FACE_HEIGHT / 2);
|
|
|
|
// Compute points for link between children and parent
|
|
let parentLinkX = didSwap
|
|
? beginSecondFaceX! + Math.floor(FACE_WIDTH / 2)
|
|
: parent_x_offset + Math.floor(memberCardWidth(pers1) / 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 the father is the father of all the children, while the
|
|
// mother is not the mother of any of the children
|
|
if (
|
|
pers2 &&
|
|
p.node.down.every(
|
|
(n) => n.member.father === pers1.id && n.member.mother !== pers2!.id
|
|
)
|
|
) {
|
|
childrenLinkX = parent_x_offset + Math.floor(memberCardWidth(pers1) / 2);
|
|
childrenLinkY = p.y + CARD_HEIGHT + 2;
|
|
}
|
|
// If the mother is the mother of all the children, while the
|
|
// father is not the father of any of the children
|
|
else if (
|
|
pers2 &&
|
|
p.node.down.every(
|
|
(n) => n.member.father !== pers1.id && n.member.mother === pers2!.id
|
|
)
|
|
) {
|
|
childrenLinkX = beginSecondFaceX! + Math.floor(memberCardWidth(pers2) / 2);
|
|
childrenLinkY = p.y + CARD_HEIGHT + 2;
|
|
}
|
|
|
|
// Normal couple
|
|
else if (p.node.spouse) {
|
|
childrenLinkX = Math.floor((endFirstFaceX + beginSecondFaceX!) / 2);
|
|
childrenLinkY = middleParentFaceY;
|
|
}
|
|
|
|
// Single person
|
|
else {
|
|
childrenLinkX = parent_x_offset + Math.floor(memberCardWidth(pers1) / 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={pers1} />
|
|
|
|
{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={pers2!} />
|
|
</>
|
|
)}
|
|
|
|
{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.displayBirthDeathShort, BIRTH_FONT)
|
|
)}
|
|
dy="14"
|
|
font-size="10"
|
|
fontFamily="Roboto"
|
|
>
|
|
{p.member.displayBirthDeathShort}
|
|
</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>
|
|
);
|
|
}
|