diff --git a/dist/types/familyTree.d.ts b/dist/types/familyTree.d.ts index dec1710..1eafdbd 100644 --- a/dist/types/familyTree.d.ts +++ b/dist/types/familyTree.d.ts @@ -122,4 +122,19 @@ export declare class FamilyTree { * @param render - If true, re-imports and re-renders the tree (default: true). */ deleteLink(sourceId: string, targetId: string, render?: boolean): void; + /** + * Deletes a person and all of their descendants (unions + persons) from the tree. + * + * Expected link semantics (as used by this library examples): + * - [personId, unionId] => person is a partner in that union (an "own union") + * - [unionId, personId] => person is a child of that union (a "parent union") + * + * Start handling: + * - If the deleted person is `data.start`, try to promote a parent (a partner of one of the person's parent unions). + * - If no parent can be found, fall back to any remaining person (or '' if none remain). + * + * @param id - The person ID to delete (together with their descendant tree). + * @param render - If true, re-import and re-render the tree (default: true). + */ + deletePersonWithTree(id: string, render?: boolean): void; } diff --git a/examples/deletePersonWithTree.html b/examples/deletePersonWithTree.html new file mode 100644 index 0000000..fcfe47f --- /dev/null +++ b/examples/deletePersonWithTree.html @@ -0,0 +1,318 @@ + + + + + + + FamilyTree - deletePersonWithTree() test + + + + + +

FamilyTree manual test: deletePersonWithTree()

+ +
+
+

Controls

+
+
+ Open DevTools console to see before/after snapshots of persons, unions, + links, and start. +
+ +
+ + +
+ +
+ +
Scenario A: delete a non-start ancestor. Should delete descendants & their + unions, without leaving dangling links.
+ + +
Scenario B: delete the start person. Should promote a parent if + available, else fallback to remaining person.
+ + +
Scenario C: delete the child of the start person. Should remove the linked + children.
+ + +
Comparison: old deletePerson (may leave dangling unions/children)
+ + +
+ +
Quick sanity: call and re-render is expected to happen automatically if your + method calls reimportData().
+
+ +

Data snapshot

+
{}
+
+ +
+

Rendered tree

+
+
+ If you don’t see anything, check the bundle path in the HTML and make sure you built the UMD bundle + (global FT). +
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/src/familyTree.ts b/src/familyTree.ts index 9ade788..003150d 100644 --- a/src/familyTree.ts +++ b/src/familyTree.ts @@ -17,7 +17,7 @@ import { warn } from 'console'; */ export interface FamilyTreeOptions extends D3DAGLayoutCalculatorOptions, - D3RendererOptions { + D3RendererOptions { /** If true, all nodes are set to visible on initialization. */ showAll: boolean; } @@ -278,4 +278,149 @@ export class FamilyTree { ); if (render) this.reimportData(); } + + + /** + * Deletes a person and all of their descendants (unions + persons) from the tree. + * + * Expected link semantics (as used by this library examples): + * - [personId, unionId] => person is a partner in that union (an "own union") + * - [unionId, personId] => person is a child of that union (a "parent union") + * + * Start handling: + * - If the deleted person is `data.start`, try to promote a parent (a partner of one of the person's parent unions). + * - If no parent can be found, fall back to any remaining person (or '' if none remain). + * + * @param id - The person ID to delete (together with their descendant tree). + * @param render - If true, re-import and re-render the tree (default: true). + */ + public deletePersonWithTree(id: string, render: boolean = true) { + // 0) Guard: if the person doesn't exist, do nothing. + if (!(id in this.data.persons)) return; + + // 1) Build fast directed adjacency maps from the `links` array. + // + // We want to quickly answer: + // - outgoing neighbors of a node: out.get(a) => all b such that [a, b] exists + // - incoming neighbors of a node: inn.get(b) => all a such that [a, b] exists + // + // Using Maps+Sets makes traversal O(E) instead of repeatedly scanning links (O(E) each time). + const out = new Map>(); + const inn = new Map>(); + + const add = (m: Map>, a: string, b: string) => { + if (!m.has(a)) m.set(a, new Set()); + m.get(a)!.add(b); + }; + + for (const [a, b] of this.data.links) { + add(out, a, b); // a -> b + add(inn, b, a); // b <- a + } + + // 2) Helpers to recognize node IDs (persons vs unions). + // This avoids accidentally following edges to missing/invalid nodes. + const isPerson = (x: string) => x in this.data.persons; + const isUnion = (x: string) => x in this.data.unions; + + // 3) We'll collect everything to delete in two sets: + // - personsToDelete: the person + all descendants (children, grandchildren, ...) + // - unionsToDelete: unions belonging to the deleted line (their "own unions") + const personsToDelete = new Set(); + const unionsToDelete = new Set(); + + // 4) Traverse ONLY the descendant direction: + // + // person -> (outgoing unions where they're a partner) -> (children of those unions) -> (their unions) -> ... + // + // This avoids deleting ancestors/spouses not in the descendant branch. + const personQueue: string[] = [id]; + + while (personQueue.length) { + // Take one person to process. + const pid = personQueue.pop()!; + + // Skip if already handled. + if (personsToDelete.has(pid)) continue; + + // Mark this person to be deleted. + personsToDelete.add(pid); + + // 4a) Find this person's "own unions": + // partner edge is [person, union] so we look at outgoing edges from the person. + for (const uid of out.get(pid) ?? []) { + // Only follow if it’s truly a union ID. + if (!isUnion(uid)) continue; + + // Mark the union for deletion (it belongs to the removed subtree). + unionsToDelete.add(uid); + + // 4b) For that union, find its children: + // child edge is [union, childPerson], so we look at outgoing edges from the union. + for (const childId of out.get(uid) ?? []) { + if (!isPerson(childId)) continue; + + // Queue the child person for processing (descend recursively). + if (!personsToDelete.has(childId)) personQueue.push(childId); + } + } + } + + // 5) If the deleted person was the start of the tree, we must choose a new start, + // otherwise the rendering/import will break or show an unexpected root. + if (this.data.start === id) { + let newStart: string | undefined; + + // 5a) Identify the person's "parent unions" (the union(s) where this person is a child). + // + // child edge is [union, person], so these are *incoming* neighbors of `id` that are unions. + const parentUnions = Array.from(inn.get(id) ?? []).filter(isUnion); + + // 5b) For each parent union, try to pick one partner as the new start. + // + // partner edge is [partnerPerson, union], so partners are *incoming* neighbors of the union that are persons. + // We also ensure we do NOT pick someone who is being deleted (rare but possible with malformed data). + for (const pu of parentUnions) { + const partners = Array.from(inn.get(pu) ?? []).filter( + (p) => isPerson(p) && !personsToDelete.has(p) + ); + if (partners.length) { + newStart = partners[0]; + break; + } + } + + // 5c) If we couldn't promote a parent, fall back to any remaining person in the dataset. + if (!newStart) { + newStart = Object.keys(this.data.persons).find( + (p) => !personsToDelete.has(p) + ); + } + + // 5d) If the tree becomes empty, start becomes '' (or you could set it to undefined if your type allows it). + this.data.start = newStart ?? ''; + } + + // 6) Remove all links that touch anything we are deleting. + // + // This prevents dangling edges that point to missing nodes. + this.data.links = this.data.links.filter(([a, b]) => { + return ( + !personsToDelete.has(a) && + !personsToDelete.has(b) && + !unionsToDelete.has(a) && + !unionsToDelete.has(b) + ); + }); + + // 7) Delete unions and persons from their dictionaries. + // + // We delete unions first just for neatness (either order is fine after links are filtered). + for (const uid of unionsToDelete) delete this.data.unions[uid]; + for (const pid of personsToDelete) delete this.data.persons[pid]; + + // 8) Re-import/re-render if requested (this matches the behavior of your other mutation methods). + if (render) this.reimportData(); + } + } diff --git a/src/layout/d3-dag.ts b/src/layout/d3-dag.ts index c61fc75..a0e9d47 100644 --- a/src/layout/d3-dag.ts +++ b/src/layout/d3-dag.ts @@ -42,6 +42,14 @@ export interface D3DAGLayoutCalculatorOptions extends LayoutCalculatorOpts { coord: Coord; /** Orientation of the layout (horizontal or vertical). */ orientation: Orientation; + /** + * Distance between sibling nodes (in layout units). + * Controls horizontal spacing in horizontal orientation and vertical spacing + * in vertical orientation. Defaults to 50. + * Note: this is only used by the default nodeSize function. + * When providing a custom nodeSize, set spacing there directly. + */ + siblingDistance: number; } /** @@ -187,10 +195,17 @@ export class D3DAGLayoutCalculator implements LayoutCalculator { decross: customSugiyamaDecross, coord: coordQuad(), orientation: Horizontal, + siblingDistance: 50, }; constructor(opts?: Partial) { this.opts = { ...this.opts, ...opts }; + // If siblingDistance was provided but nodeSize was not customised, + // rebuild the default nodeSize with the requested spacing. + if (opts?.siblingDistance !== undefined && opts?.nodeSize === undefined) { + const d = this.opts.siblingDistance; + this.opts.nodeSize = () => [d, 100] as [number, number]; + } } /** diff --git a/src/render/d3.ts b/src/render/d3.ts index fd4e33f..f58f80a 100644 --- a/src/render/d3.ts +++ b/src/render/d3.ts @@ -12,6 +12,14 @@ import type { Renderer } from './types'; import type { FamilyTree } from '../familyTree'; import type { PersonData } from '../import/types'; +/** + * D3 selection type for a single node group ( element bound to a LayoutedNode). + * Parent generics are because this is produced by select(element) + * directly on a DOM node, which creates a root-level selection with no parent context. + * This is the type received by nodeRenderFunction and nodeUpdateFunction. + */ +export type NodeGroupSelection = Selection; + /** * Options for configuring the D3Renderer. * Allows customization of transitions, link and node rendering, labeling, @@ -39,6 +47,54 @@ export interface D3RendererOptions { node: LayoutedNode, missingData?: string ): string | undefined; + /** + * Function called once per *entering* node to render its visual content + * inside the positioned element. + * Use this to replace the default circle with portraits, cards, icons, etc. + * + * @param group - D3 selection of the entering (single node, already bound to data) + * @param node - The LayoutedNode bound to this group + * @param opts - Full renderer opts; use nodeSizeFunction, nodeCSSClassFunction, etc. + * @param ft - The FamilyTree instance; pass to nodeClickFunction / nodeRightClickFunction + */ + nodeRenderFunction( + group: NodeGroupSelection, + node: LayoutedNode, + opts: D3RendererOptions, + ft: FamilyTree + ): void; + /** + * Function called on every re-render for *already-present* nodes (the D3 update selection). + * Must keep visual state in sync with whatever nodeRenderFunction produced. + * The default implementation updates the CSS class on the circle. + * + * @param group - D3 selection of the existing (single node, already bound to data) + * @param opts - Full renderer opts + */ + nodeUpdateFunction( + group: NodeGroupSelection, + opts: D3RendererOptions + ): void; + /** + * Function called per node to determine the horizontal offset (in SVG units) + * between the node centre and the start of its text label. + * Used in horizontal orientation — return a value large enough to clear + * whatever nodeRenderFunction draws (for the default circle: radius + margin). + * + * @param node - The LayoutedNode being labelled + * @param opts - Full renderer opts (use nodeSizeFunction to derive the value) + */ + nodeLabelOffsetFunction(node: LayoutedNode, opts: D3RendererOptions): number; + /** + * Function called per node to determine the vertical offset (in SVG units) + * between the node centre and the baseline of its text label. + * Used in vertical orientation, where the label is placed below the node + * and centred horizontally. + * + * @param node - The LayoutedNode being labelled + * @param opts - Full renderer opts (use nodeSizeFunction to derive the value) + */ + nodeLabelVerticalOffsetFunction(node: LayoutedNode, opts: D3RendererOptions): number; } /** @@ -78,6 +134,10 @@ export class D3Renderer implements Renderer { nodeLabelFunction: D3Renderer.defaultNodeLabelFunction, nodeTooltipFunction: D3Renderer.defaultNodeTooltipFunction, nodeSizeFunction: D3Renderer.defaultNodeSizeFunction, + nodeRenderFunction: D3Renderer.defaultNodeRenderFunction, + nodeUpdateFunction: D3Renderer.defaultNodeUpdateFunction, + nodeLabelOffsetFunction: D3Renderer.defaultNodeLabelOffsetFunction, + nodeLabelVerticalOffsetFunction: D3Renderer.defaultNodeLabelVerticalOffsetFunction, }; /** @@ -252,6 +312,70 @@ export class D3Renderer implements Renderer { return class1 + ' ' + class2; } + /** + * Default function to render the visual content of an entering node. + * Appends a circle to the group, wired to click/contextmenu handlers, + * with radius from nodeSizeFunction and class from nodeCSSClassFunction. + * Override via opts.nodeRenderFunction to replace circles with portraits, + * cards, or any other SVG content. + */ + private static defaultNodeRenderFunction( + group: NodeGroupSelection, + node: LayoutedNode, + opts: D3RendererOptions, + ft: FamilyTree + ) { + group + .append('circle') + .on('click', (event, d) => opts.nodeClickFunction(d, ft)) + .on('contextmenu', (event, d) => opts.nodeRightClickFunction(d, ft)) + .transition() + .duration(opts.transitionDuration) + .attr('r', opts.nodeSizeFunction) + .attr('class', (d) => opts.nodeCSSClassFunction(d)); + } + + /** + * Default function to update the visual content of an already-present node + * on re-renders (the D3 update selection). + * Mirrors what defaultNodeRenderFunction produced: refreshes the CSS class + * on the circle so extendable/non-extendable state stays in sync. + * Override via opts.nodeUpdateFunction whenever you override nodeRenderFunction. + */ + private static defaultNodeUpdateFunction( + group: NodeGroupSelection, + opts: D3RendererOptions + ) { + group + .select('circle') + .attr('class', (d) => opts.nodeCSSClassFunction(d)); + } + + /** + * Default function to determine the horizontal label offset for a node. + * Returns nodeSizeFunction(node) + 3, placing the label just outside the + * default circle. Override when nodeRenderFunction draws something larger + * (e.g. a portrait with radius 28 → return 28 + 4 = 32). + */ + private static defaultNodeLabelOffsetFunction( + node: LayoutedNode, + opts: D3RendererOptions + ): number { + return opts.nodeSizeFunction(node) + 3; + } + + /** + * Default function to determine the vertical label offset for a node in + * vertical orientation. Returns nodeSizeFunction(node) + 5, placing the + * label just below the default circle, centred horizontally. + */ + private static defaultNodeLabelVerticalOffsetFunction( + node: LayoutedNode, + opts: D3RendererOptions + ): number { + return opts.nodeSizeFunction(node) + 5; + } + /** * Default function to determine the CSS class for a link. * Returns 'link' for all links. @@ -286,14 +410,15 @@ export class D3Renderer implements Renderer { .duration(this.opts.transitionDuration) .attr('class', 'node-group') .attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')'); - enteringGroups - .append('circle') - .on('click', (event, d) => this.opts.nodeClickFunction(d, this.ft)) - .on('contextmenu', (event, d) => this.opts.nodeRightClickFunction(d, this.ft)) - .transition() - .duration(this.opts.transitionDuration) - .attr('r', this.opts.nodeSizeFunction) - .attr('class', (d) => this.opts.nodeCSSClassFunction(d)); + // delegate visual rendering to the (overridable) nodeRenderFunction + enteringGroups.each((d, i, nodes) => { + this.opts.nodeRenderFunction( + select(nodes[i]), + d, + this.opts, + this.ft + ); + }); // exiting nodes move from current position to clicked node new position selection .exit() @@ -304,13 +429,17 @@ export class D3Renderer implements Renderer { return 'translate(' + transitionEnd.x + ',' + transitionEnd.y + ')'; }) .remove(); - // update existing nodes + // update existing nodes — position via transition, visuals via nodeUpdateFunction selection .transition() .duration(this.opts.transitionDuration) - .attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')') - .select('circle') - .attr('class', (d) => this.opts.nodeCSSClassFunction(d)); + .attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')'); + selection.each((d, i, nodes) => { + this.opts.nodeUpdateFunction( + select(nodes[i]), + this.opts + ); + }); return enteringGroups; } @@ -411,38 +540,63 @@ export class D3Renderer implements Renderer { /** * Renders multi-line labels for entering nodes. * Each line is rendered as a separate element. + * + * In **horizontal** orientation the label sits to the right of the node: + * - `x` is set to `xOffset` (positive = right of centre) + * - `text-anchor` is `start` + * + * In **vertical** orientation the label sits below the node, centred: + * - The first tspan's `dy` is `yOffset` (vertical clearance from centre) + * - `x` is 0 and `text-anchor` is `middle` + * * @param enteringNodes - The selection of entering nodes. + * @param orientation - Layout orientation ('horizontal' | 'vertical'). * @param cssClass - CSS class for the text element. * @param lineSep - Vertical separation between lines. - * @param xOffset - Horizontal offset for the text. + * @param xOffset - Horizontal offset used in horizontal orientation. + * @param yOffset - Vertical offset used in vertical orientation. * @param dominantBaseline - SVG dominant-baseline attribute value. */ private renderLabels( enteringNodes: Selection, + orientation: Orientation = Vertical, cssClass: string = 'node-label', lineSep: number = 14, - xOffset: number = 13, + xOffset: number | ((node: LayoutedNode) => number) = 13, + yOffset: number | ((node: LayoutedNode) => number) = 15, dominantBaseline: DominantBaseline = 'central' ) { const nodeLabelFunction = this.opts.nodeLabelFunction; - enteringNodes + const resolveXOffset = typeof xOffset === 'function' ? xOffset : () => xOffset as number; + const resolveYOffset = typeof yOffset === 'function' ? yOffset : () => yOffset as number; + const isVertical = orientation === Vertical; + + const textSel = enteringNodes .append('text') .attr('class', cssClass) .attr('dominant-baseline', dominantBaseline) + .attr('text-anchor', isVertical ? 'middle' : 'start'); + + textSel .selectAll('tspan') .data((node) => { const lines = nodeLabelFunction(node); - const yOffset = (-lineSep * (lines.length - 1)) / 2; - return lines.map((line, i) => ({ - line, - dy: i === 0 ? yOffset : lineSep, - })); + return lines.map((line, i) => ({ line, node, i, total: lines.length })); }) .enter() .append('tspan') .text((d) => d.line) - .attr('x', xOffset) - .attr('dy', (d) => d.dy); + .attr('x', (d) => isVertical ? 0 : resolveXOffset(d.node)) + .attr('dy', (d) => { + if (isVertical) { + // first line: drop below node; subsequent lines: line separation + return d.i === 0 ? resolveYOffset(d.node) : lineSep; + } else { + // original horizontal behaviour: vertically centre the block + const yOff = (-lineSep * (d.total - 1)) / 2; + return d.i === 0 ? yOff : lineSep; + } + }); } /** @@ -484,7 +638,15 @@ export class D3Renderer implements Renderer { this.sortDomElements(); // add tooltips and node labels this.setupTooltips(nodeSelect); - this.renderLabels(nodeSelect, 'node-label', 14, 13, 'central'); + this.renderLabels( + nodeSelect, + layoutResult.orientation, + 'node-label', + 14, + (node) => this.opts.nodeLabelOffsetFunction(node, this.opts), + (node) => this.opts.nodeLabelVerticalOffsetFunction(node, this.opts), + 'central' + ); // center view on clicked node // work-around because JSDOM+d3-zoom throws errors if (!this.isJSDOM) { @@ -504,4 +666,4 @@ export class D3Renderer implements Renderer { clear() { this.g.selectAll('*').remove(); } -} +} \ No newline at end of file