466 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			466 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";
 | |
| 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>
 | |
|   );
 | |
| }
 |