@naviary-sanctuary/kinship-graph
v0.3.1
Published
A deterministic D3 pedigree graph renderer for kinship schema v1.
Maintainers
Readme
kinship-graph

Framework-independent D3 renderer for the graph produced by
@naviary-sanctuary/kinship.
kinship owns pedigree topology. kinship-graph turns that topology into a
couple-first, generation-based SVG family tree for browsers or a deterministic
layout for server-side work.
Features
- Partners stay on the same visual generation, even when their source
lineageDepthvalues differ, in the default pedigree layout. - An optional generation-row layout aligns all components by source lineage depth and groups founders under presentation-only origin headers.
- A family is drawn as one familiar connector: couple line, downward trunk, sibling bus, and child stems.
focusIdanddepthproject a smaller family view without changing source data.- Missing images can use a configurable
defaultImage. - D3 pan and zoom, keyboard navigation, accessible names, and responsive fit are included.
- Nodes, family markers, and lines can be styled with CSS variables or DOM callbacks.
- The layout entrypoint has no DOM dependency and works in Node.js.
Install
This README documents the kinship-graph 0.2.0 API, which consumes
the schema version 1 produced by kinship 0.2.x. The legacy kinship
0.1.x API is incompatible with these examples.
Applications that receive schema version 1 JSON from a backend need only the
renderer:
npm install @naviary-sanctuary/kinship-graph@^0.2.0d3 is a runtime dependency and is installed automatically with the renderer.
Applications upgrading from 0.1.0 can remove their direct d3 dependency if
they do not use D3 themselves.
The package is ESM-only, uses import exports, and supports Node.js 20.19 or
newer. Browser rendering requires a DOM; server code should use the /layout
entrypoint.
Install kinship separately only when the same runtime also builds topology:
npm install @naviary-sanctuary/kinship@^0.2.0kinship is not a runtime or peer dependency of kinship-graph. A normal
frontend receives result.graph from its backend rather than bundling the
topology engine.
End-to-end quick start
This same-runtime example shows the complete topology-to-renderer handoff:
import { Kinship } from '@naviary-sanctuary/kinship';
import { KinshipGraph } from '@naviary-sanctuary/kinship-graph';
const result = new Kinship({
parrots: [
{ id: 'father', gender: 'male' },
{ id: 'mother', gender: 'female' },
{
id: 'child',
fatherId: 'father',
motherId: 'mother',
gender: 'unknown',
},
],
pairs: [
{
id: 'pair-1',
maleId: 'father',
femaleId: 'mother',
},
],
}).build();
if (!result.ok) {
// Map the structured issues to the application's validation contract.
throw new Error('Pedigree input is invalid.');
}
const graph = new KinshipGraph('#pedigree', {
height: 680,
defaultImage: '/images/default-parrot.svg',
onNodeActivate: ({ layoutNode }) => {
console.log(layoutNode.node.individualId);
},
});
graph.render({
graph: result.graph,
individuals: [
{ id: 'father', name: 'Atlas', image: '/images/atlas.webp' },
{ id: 'mother', name: 'Iris', image: '/images/iris.webp' },
{ id: 'child', name: 'Nova' },
],
});Successful builds may still include warnings. Preserve result.issues in the
API response or application state and handle them separately; kinship-graph
accepts only result.graph and neither displays nor discards build issues.
The presentation records are joined by individualId. They may contain any
additional application fields.
Input contract
The renderer accepts the JSON-safe schema version 1 emitted by kinship:
interface KinshipGraphRenderInput<TIndividual> {
readonly graph: KinshipGraphData;
readonly individuals?: readonly TIndividual[];
readonly focusId?: string;
readonly depth?: number;
readonly includePartners?: boolean;
readonly includeSiblings?: boolean;
}Opaque graph node IDs are never parsed. focusId and presentation record IDs
always use the domain individualId.
In the default pedigree layout, lineageDepth is validated as source topology
metadata but is deliberately not used as a screen row. An external partner may have depth 0 while their partner
has depth 3; the renderer still places the couple together.
Focus and depth
| Input | Result |
| ------------------------- | ---------------------------------------------- |
| No focusId | All graph components |
| focusId only | The focus person's complete component |
| focusId, depth: 0 | Focus and enabled partners and siblings |
| focusId, depth: n | Relatives within n hops plus enabled context |
| depth without focusId | Throws DEPTH_REQUIRES_FOCUS_ID |
Family junction nodes do not consume depth. Partners are same-generation context and do not recursively pull in their other relatives. Siblings are included only when their shared parent connector is visible.
includePartners and includeSiblings default to true for a depth-limited
view. Both options require focusId and depth; supplying either option
without depth throws INVALID_OPTIONS.
Layout model
The default layoutMode: 'pedigree' normalizes the graph into people and families.
- Every visible partner relationship forms a same-generation unit.
- Parent-family to child-unit relationships determine visual generation.
- Units are ordered to keep partners adjacent and siblings together.
- A family junction is placed between its partners.
- Children share one orthogonal trunk and sibling bus.
One biological individual normally has one layout occurrence. When a third
partner, a partner cycle, a cross-generation relationship, or pedigree collapse
makes one position geometrically impossible, the layout emits another
occurrence with the same source node and a distinct occurrenceId. This keeps
every couple adjacent and every biological child below its family without
inventing a new individual. In a depth-limited view, generationOffset belongs
to the occurrence so repeated appearances can accurately occupy different
generations.
The default layout never routes ordinary relationships around the outside edge of the canvas and does not use background-colored line halos.
Generation rows and founder origins
Use layoutMode: 'generations' to put each individual on exactly one row,
numbered lineageDepth + 1, across all connected and disconnected families.
Each connected family occupies its own horizontal block, with couples and
siblings kept together. Biological connectors stay within each family block.
Each origin appears once in a shared row above the families and branches to its
founders, including founders in different family blocks.
Founders remain in generation 1 even when paired with a later generation.
Cross-generation partner connections use outer routing lanes so they do not
pass through intervening portraits. Focus and depth still select biological
relatives; rows retain their absolute generation numbers.
const renderer = new KinshipGraph('#pedigree', {
layoutMode: 'generations',
getGenerationLabel: (generation) => `${generation}세대`,
});
renderer.render({
graph: result.graph,
founderGroups: [
{ id: 'origin-aa', label: 'aa', individualIds: ['A', 'B'] },
{ id: 'origin-bb', label: 'bb', individualIds: ['C', 'D', 'E'] },
],
});founderGroups is display metadata for this layout mode. Supply only resolved
individuals without a parent family in the full source graph. A parent outside
the focused view, or a referenced parent without a record, does not make a child
a founder. Group IDs and member IDs must be unique; the layout rejects unknown
members and non-founders. Group order and member order are deterministic.
Visible layout origins retain the supplied group IDs, regardless of how many
family blocks contain their founders.
Origin headers and dashed connectors never become pedigree nodes or edges and
never imply shared ancestry. Applications decide how to obtain and label origin
metadata, including missing origins. getLayout() exposes founderGroups and
generationRows in this mode; visible headers and origin connectors count toward
the rendering safety limits.
Browser API
new KinshipGraph(container, options?)
container is an HTMLElement or selector for an existing HTML element.
render(input)
Validates, lays out, and renders the input. Returns the serializable
KinshipGraphLayout.
getLayout()
Returns the last layout or null before the first render.
fit(options?)
fit() fits the entire pedigree inside the viewport, respecting minZoom.
Use fit({ minScale: 0.8 }) for a readable starting view without restricting
later manual zooming. minScale must be finite and within minZoom and
maxZoom; the last fit choice is preserved when the viewport resizes.
With scroll: true, a graph larger than the viewport starts at the top left,
or centers the focused individual.
For example, set minZoom: 0.0001 and initialFit: false, then call
fit({ minScale: 0.8 }) after rendering. The user can zoom out freely or call
fit() to see the whole graph.
zoomBy(factor)
Changes the current scale by a positive factor. It validates the factor and
does nothing when the renderer was created with zoom: false.
destroy()
Disconnects observers and event listeners and removes only renderer-owned DOM. The instance cannot be reused.
Options
Layout
| Option | Default | Meaning |
| ----------------- | ---------: | ------------------------------------------- |
| layoutMode | pedigree | Default couple layout or generations rows |
| nodeWidth | 112 | Horizontal node footprint |
| nodeHeight | 144 | Vertical node footprint |
| horizontalGap | 52 | Space between people and family units |
| generationGap | 60 | Space between generation footprints |
| componentGap | 96 | Space between disconnected components |
| padding | 48 | Layout padding |
| maxVisibleNodes | 2000 | Projected node safety limit |
| maxVisibleEdges | 6000 | Projected edge safety limit |
The effective horizontalGap is never less than 12 pixels. Generation rows
expand beyond generationGap when connector lanes need more clearance to stay
outside person footprints.
Browser
| Option | Default | Meaning |
| -------------- | -----------------------: | ------------------------------------------------- |
| height | 640 | SVG viewport height |
| minZoom | 0.2 | Minimum scale |
| maxZoom | 4 | Maximum scale |
| zoom | true | Enable interaction and zoomBy() |
| scroll | false | Native scrollbars and wheel/trackpad panning |
| initialFit | true | Fit after rendering and container resize |
| ariaLabel | Kinship pedigree graph | Accessible graph name |
| defaultImage | null | Image used when a record image is absent or fails |
onNodeActivate runs for click, Enter, or Space activation.
onNodeFocus runs whenever browser focus enters a person node; both callbacks
receive the layout node, presentation record, and originating event.
getGenerationLabel(generation) customizes generation-row labels, starting at
1; the default is Generation 1, Generation 2, and so on.
All getter, render, and interaction callbacks are synchronous; returned
promises are ignored.
With scroll: true, normal wheel and trackpad gestures move through the graph;
Shift + wheel moves horizontally. Native scrollbars stay in sync with dragging
and zooming, and keyboard navigation reveals the focused individual. Without
this option, normal wheel scrolling is left to the page. Use pinch or
Ctrl/Command + wheel to zoom the graph.
Presentation getters
import { KinshipGraph, type KinshipGraphIndividual } from '@naviary-sanctuary/kinship-graph';
interface MyIndividual extends KinshipGraphIndividual {
readonly displayName?: string;
readonly bandNumber?: string;
readonly portraitUrl?: string;
}
const graph = new KinshipGraph<MyIndividual>('#pedigree', {
getLabel: (individual, node) => individual?.displayName ?? node.individualId,
getDescription: (individual) => individual?.bandNumber,
getImage: (individual) => individual?.portraitUrl,
getAriaLabel: (individual, layoutNode) =>
`${individual?.displayName ?? layoutNode.node.individualId}, ${layoutNode.relation}`,
});getLabel falls back to the presentation record and then the individual ID
when it returns a blank or non-string value. Returning undefined from
getDescription or getImage delegates to the presentation record. A blank or
non-string description clears the description; a blank or non-string image uses
defaultImage. Invalid getAriaLabel results use the generated accessible
label.
DOM customization
Callbacks run after the default SVG content is updated:
import { KinshipGraph, type KinshipGraphIndividual } from '@naviary-sanctuary/kinship-graph';
interface TaggedIndividual extends KinshipGraphIndividual {
readonly tags?: readonly string[];
}
const graph = new KinshipGraph<TaggedIndividual>('#pedigree', {
renderNode: ({ element, layoutNode, individual }) => {
element.classList.toggle('is-founder', individual?.tags?.includes('founder') ?? false);
element.dataset.appRelation = layoutNode.relation;
},
renderFamily: ({ element, layoutFamily }) => {
element.dataset.origin = layoutFamily.node.origin;
},
renderEdge: ({ element, layoutEdge }) => {
element.style.strokeWidth = layoutEdge.edge.kind === 'partner' ? '3' : '2.5';
},
});These callbacks receive live renderer-owned elements. Treat them as trusted application code and remove only content that your callback owns.
CSS custom properties
Set variables on the graph container:
.pedigree {
--kinship-graph-background: #e7f1ed;
--kinship-graph-line: #3e7779;
--kinship-graph-partner-line: var(--kinship-graph-line);
--kinship-graph-child-line: var(--kinship-graph-line);
--kinship-graph-focus: #bf5134;
--kinship-graph-node-fill: #fffdfa;
--kinship-graph-node-stroke: #ffffff;
--kinship-graph-male: #dcecf5;
--kinship-graph-female: #f2e1ec;
--kinship-graph-unknown: #e1ebe7;
--kinship-graph-text: #17342d;
--kinship-graph-muted-text: #61736d;
--kinship-graph-generation-line: #dce6e2;
--kinship-graph-component-fill: #f8faf9;
--kinship-graph-component-stroke: #e4ebe7;
--kinship-graph-origin-fill: #eaf4ef;
--kinship-graph-origin-stroke: #9fb9ad;
--kinship-graph-origin-text: #245844;
--kinship-graph-origin-line: #9fb9ad;
--kinship-graph-font: ui-sans-serif, system-ui, sans-serif;
}Stable class names include:
.kinship-graph.kinship-graph__viewport.kinship-graph__edge.kinship-graph__edge--partner.kinship-graph__edge--child.kinship-graph__family.kinship-graph__node.kinship-graph__avatar.kinship-graph__node-label.kinship-graph__node-description.kinship-graph__generation.kinship-graph__component.kinship-graph__origin.kinship-graph__origin-connector
React
Create one renderer for the mounted container, render when data changes, and destroy it during effect cleanup:
import { useEffect, useRef } from 'react';
import { KinshipGraph, type KinshipGraphData, type KinshipGraphIndividual } from '@naviary-sanctuary/kinship-graph';
interface PedigreeProps {
readonly graph: KinshipGraphData;
readonly individuals: readonly KinshipGraphIndividual[];
readonly focusId?: string;
}
export function Pedigree({ graph, individuals, focusId }: PedigreeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<KinshipGraph<KinshipGraphIndividual> | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const renderer = new KinshipGraph<KinshipGraphIndividual>(containerRef.current, {
defaultImage: '/default-parrot.svg',
});
rendererRef.current = renderer;
return () => {
renderer.destroy();
rendererRef.current = null;
};
}, []);
useEffect(() => {
rendererRef.current?.render({
graph,
individuals,
...(focusId ? { focusId, depth: 3 } : {}),
});
}, [graph, individuals, focusId]);
return <div ref={containerRef} />;
}Static layout and SSR
Use the DOM-free entrypoint:
import { KinshipGraphLayoutEngine } from '@naviary-sanctuary/kinship-graph/layout';
const layout = new KinshipGraphLayoutEngine().layout({
graph,
individuals,
focusId: 'NAV-001',
depth: 3,
});KinshipGraph.layout(input, options) provides the same calculation from the
main browser entrypoint.
Accessibility
- The SVG has a configurable accessible name and an updated structural description.
- Every person is keyboard focusable through a roving tab stop.
- Arrow keys move spatially; Home and End move to the first and last person.
- Enter and Space call
onNodeActivate. - Focus is shown with a high-contrast ring, not color alone.
- Default rendering uses SVG text nodes and never injects presentation strings as HTML.
Errors and limits
Expected failures throw KinshipGraphError with a stable code and structured
details. Important codes include:
DEPTH_REQUIRES_FOCUS_IDFOCUS_NOT_FOUNDINVALID_GRAPHINVALID_EDGEINVALID_COMPONENTINVALID_OPTIONSVIEW_LIMIT_EXCEEDEDDESTROYED
Project large datasets with focusId and depth, and set explicit safety
limits appropriate for the host application.
Development
bun install
bun run check
bunx playwright install chromium
bun run check:fullSee CONTRIBUTING.md for the complete contributor workflow.
License
MIT
