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"; import "svg2pdf.js"; 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 lastCoupleId = tree.couples?.length ?? 1; const lastCouple = tree.couples?.[lastCoupleId - 1]; // 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> ); }