6 Commits

Author SHA1 Message Date
f135287257 Draw first links
All checks were successful
continuous-integration/drone/push Build is passing
2023-08-26 14:13:35 +02:00
474a614130 Ready to draw filiation links 2023-08-26 13:56:29 +02:00
1c15fd17db Draw couples links 2023-08-26 13:39:32 +02:00
e07b093dfc Fix text size rendering 2023-08-26 13:17:04 +02:00
ed5bd63bf6 Display member tiles recursively 2023-08-26 12:22:39 +02:00
cb79a2755e Start to build simple tree 2023-08-26 12:01:40 +02:00
4 changed files with 358 additions and 2 deletions

View File

@@ -112,7 +112,7 @@ export class Member implements MemberDataApi {
return firstName.length === 0
? this.last_name ?? ""
: `${firstName} ${this.last_name ?? ""}`;
: `${firstName} ${this.last_name?.toUpperCase() ?? ""}`;
}
get invertedFullName(): string {
@@ -159,6 +159,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 ||

View File

@@ -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,6 +148,7 @@ 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>
@@ -154,6 +157,12 @@ export function FamilyMemberTreeRoute(): React.ReactElement {
<Paper style={{ flex: "1" }}>
{currTab === CurrTab.BasicTree ? (
<BasicFamilyTree tree={tree!} depth={currDepth} />
) : currTab === CurrTab.SimpleTree ? (
<SimpleFamilyTree
tree={tree!}
isUp={currMode === TreeMode.Ascending}
depth={currDepth}
/>
) : (
<ComplexFamilyTree
tree={tree!}

View 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;
}

View File

@@ -0,0 +1,310 @@
import React from "react";
import { Couple } from "../../api/CoupleApi";
import { Member } from "../../api/MemberApi";
import { FamilyTreeNode } from "../../utils/family_tree";
import { getTextWidth } from "../../utils/render_utils";
const FACE_WIDTH = 60;
const FACE_HEIGHT = 70;
const FACE_TEXT_SPACING = 2;
const CARD_HEIGHT = 103;
const SPOUSE_SPACING = 10;
const CARD_SPACING = 20;
const SIBLINGS_SPACING = 20;
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[];
width: number;
}
/**
* Get the width of a member card
*/
function memberCardWidth(m: Member): number {
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 buildSimpleDownTreeNode(
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 = {
down:
childrenToProcess?.map((c) => buildSimpleDownTreeNode(c, depth - 1)) ??
[],
member: tree.member,
spouse: lastCouple
? {
couple: lastCouple.couple,
member: lastCouple.member,
}
: undefined,
width: -1,
};
// Compute current level width
let levelWidth =
memberCardWidth(node.member) +
(node.spouse ? SPOUSE_SPACING + memberCardWidth(node.spouse.member) : 0) +
CARD_SPACING;
// Compute down level width
const downWidth =
SIBLINGS_SPACING * node.down.length +
node.down.reduce((prev, n) => prev + n.width, 0);
node.width = Math.max(levelWidth, downWidth);
return node;
}
// TODO : if useless, remove and merge up and down techniques
function buildSimpleUpTreeNode(
tree: FamilyTreeNode,
depth: number
): SimpleTreeNode {
/*if (depth === 0) throw new Error("Too much recursion reached!");
return {
member: tree.member,
children:
depth === 1
? []
: tree.down?.map((c) => buildSimpleUpTreeNode(c, depth - 1)) ?? [],
};*/
return buildSimpleDownTreeNode(tree, depth);
}
/**
* Simple family tree
*
* Only one couple can be shown in this version
*/
export function SimpleFamilyTree(p: {
tree: FamilyTreeNode;
isUp: boolean;
depth: number;
}): React.ReactElement {
const tree = React.useMemo(
() =>
p.isUp
? buildSimpleUpTreeNode(p.tree, p.depth)
: buildSimpleDownTreeNode(p.tree, p.depth),
[p.tree, p.isUp, p.depth]
);
const height = p.depth * (CARD_HEIGHT + LEVEL_SPACING) - LEVEL_SPACING;
console.info(`tree width=${tree.width} height=${height}`);
return (
<svg width={tree.width} height={height} xmlns="http://www.w3.org/2000/svg">
<NodeArea node={tree} x={0} y={0} />
</svg>
);
}
function NodeArea(p: {
x: number;
y: number;
childrenLinkDestX?: number;
childrenLinkDestY?: number;
node: SimpleTreeNode;
}): React.ReactElement {
const parent_x_offset =
p.x +
Math.floor(
(p.node.width -
(memberCardWidth(p.node.member) +
(p.node.spouse
? SPOUSE_SPACING + memberCardWidth(p.node.spouse.member)
: 0))) /
2
);
let downXOffset = p.x;
let endFirstFaceX =
parent_x_offset +
Math.floor((memberCardWidth(p.node.member) - FACE_WIDTH) / 2) +
FACE_WIDTH;
let beginingOfSecondCardX =
parent_x_offset + memberCardWidth(p.node.member) + SPOUSE_SPACING;
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;
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;
}
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.fullName, NAME_FONT))}
dy="14"
font-size="13"
fontFamily="Roboto"
>
{p.member.fullName}
</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>
);
}