@captiveorigins/onepedigree-react 0.0.0-stage → 0.1.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Captive Origins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # @captiveorigins/onepedigree-react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Server-renderable SVG pedigree views for React, built on
4
+ [`@captiveorigins/onepedigree-core`](../core).
5
+
6
+ ```tsx
7
+ import { layoutPedigree, projectPedigree } from '@captiveorigins/onepedigree-core'
8
+ import { PedigreeExplorer, PedigreeView } from '@captiveorigins/onepedigree-react'
9
+ import '@captiveorigins/onepedigree-react/styles.css'
10
+
11
+ // Static (server component, no client JavaScript; nodes are ordinary links):
12
+ export async function AnimalPedigree({ document, focalId }) {
13
+ const projection = projectPedigree(document, { focalId, view: 'pedigree', ancestorDepth: 4, maxNodes: 500 })
14
+ const layout = await layoutPedigree(projection, { orientation: 'vertical' })
15
+ return <PedigreeView projection={projection} layout={layout} getIndividualHref={(a) => `/animals/${a.id}`} />
16
+ }
17
+
18
+ // Interactive (client): pan, zoom, fit, selection, highlighting, keyboard, quick view, SVG/print export.
19
+ <PedigreeExplorer projection={projection} layout={layout} height="32rem" />
20
+ ```
21
+
22
+ ## Views
23
+
24
+ All 13 views of the core: `pedigree`, `fan`, `ahnentafel`, `descendants`, `hourglass`,
25
+ `cohort`, `relationship`, `timeline`, `inbreeding`, `population`, `compact`, `maternal`,
26
+ `paternal`. Repeated individuals follow the projection's `merge`, `separate` or `hybrid` mode;
27
+ selecting one occurrence highlights every occurrence of that individual.
28
+
29
+ ## Rearranging by drag
30
+
31
+ In views where order is a choice (descendants, hourglass, cohort, population, relationship), people can drag a node sideways to reorder it among its siblings. Ancestor charts keep the father/mother convention. A node only moves within its own drawn groups (a clutch or litter frame) and among individuals with the same parents, so frames and family lines stay truthful. The data never changes: the order is a layout option you keep (and may save).
32
+
33
+ ```tsx
34
+ const [nodeOrder, setNodeOrder] = useState<string[]>([])
35
+ const layout = useMemo(() => layoutSync(projection, { nodeOrder }), [projection, nodeOrder])
36
+
37
+ <PedigreeExplorer projection={projection} layout={layout} nodeOrder={nodeOrder} onNodeOrderChange={setNodeOrder} />
38
+ ```
39
+
40
+ `dropNodeOrder(layout, occurrenceId, delta, current, groupOf)` is exported for custom UIs.
41
+
42
+ ## Themes and colors
43
+
44
+ Seven presets, each with its own per-view accents (e.g. warm maternal and cool paternal
45
+ lines, distinct highlights for the relationship and inbreeding views, fan ring shading):
46
+ `classic`, `midnight`, `forest`, `ocean`, `terracotta`, `high-contrast`, `print`.
47
+
48
+ ```tsx
49
+ <PedigreeView projection={projection} layout={layout} theme="forest" />
50
+ <PedigreeExplorer projection={projection} layout={layout} theme="ocean" themeEditor onThemeChange={save} />
51
+ ```
52
+
53
+ `themeEditor` adds a Colors button: choose a preset and adjust any color with a color picker
54
+ or hex code, for all views or only the current one, with a warning when text contrast drops
55
+ below WCAG AA. `onThemeChange` receives the edited theme (a plain object) to store; pass it
56
+ back as `theme`. `ThemeEditor` is also exported for use in your own settings pages. Themes are
57
+ rendered as inline custom properties, so they work in server rendering.
58
+
59
+ ## Brand family
60
+
61
+ OnePedigree and OneTaxonomy share a backbone (`brand/README.md`): the same line icons (`Icon`, `ICON_PATHS`), 32 px controls, a floating canvas toolbar and a Captive Origins orange focus ring, while each keeps its own views and character.
62
+
63
+ ## Styling
64
+
65
+ - Default stylesheet `styles.css` (optional; leave it out for unstyled mode).
66
+ - CSS custom properties: `--op-node-background`, `--op-node-border`, `--op-connector`,
67
+ `--op-selected-ring`, `--op-highlight`, `--op-focal-border`, `--op-label`, …
68
+ - Stable classes (`.op-root`, `.op-node`, `.op-individual`, `.op-connector`, `.op-junction`,
69
+ `.op-cohort`, `.op-fan-segment`) and data attributes (`data-individual`, `data-occurrence`,
70
+ `data-repeated`, `data-generation`, `data-cohort`, `data-reproductive-mode`, `data-sex`,
71
+ `data-focal`, `data-selected`, `data-highlighted`).
72
+ - `classNames` overrides, and render slots `renderIndividual`, `renderCohortMarker`,
73
+ `renderQuickView`.
74
+ - Headless: `@captiveorigins/onepedigree-react/headless` (hooks `usePedigreeProjection`,
75
+ `usePedigreeLayout`, `usePedigreeSelection`, `usePedigreeNavigation`,
76
+ `useOccurrenceHighlighting`, and `describeProjection`) for your own renderer.
77
+
78
+ ## Accessibility
79
+
80
+ Semantic SVG with `<title>`/`<desc>` summaries, accessible names per node, keyboard
81
+ navigation (arrows between nodes, Enter to select, Escape to clear, +/−/0 to zoom, Shift+arrows
82
+ to pan), visible focus, reduced-motion support, and non-color cues: junction shapes (circle =
83
+ two-parent mode, diamond = single-parent mode, square = variable; hollow = incomplete), line
84
+ patterns per reproductive mode, and a ↻ marker plus dashed border on repeated individuals.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,26 @@
1
+ import { type Individual, type PedigreeViewProjection } from '@captiveorigins/onepedigree-core';
2
+ import { type Terminology } from './describe.js';
3
+ export type AhnentafelListProps = {
4
+ projection: PedigreeViewProjection;
5
+ terminology?: Partial<Terminology>;
6
+ /** Words for the two parents (default Father/Mother), e.g. Sire/Dam. */
7
+ parentWords?: {
8
+ paternal: string;
9
+ maternal: string;
10
+ };
11
+ getIndividualHref?: (individual: Individual) => string | null | undefined;
12
+ onSelect?: (individualId: string) => void;
13
+ selectedIndividualId?: string | null;
14
+ className?: string;
15
+ };
16
+ /** "Father’s mother" for Ahnentafel number 6: each bit after the first is a step (0 = paternal, 1 = maternal). */
17
+ export declare function ahnentafelRelation(n: number, words?: {
18
+ paternal: string;
19
+ maternal: string;
20
+ }): string;
21
+ /**
22
+ * The Ahnentafel as genealogists use it: a numbered list of ancestors by
23
+ * generation (1 = the focal individual, 2n = father, 2n + 1 = mother), with
24
+ * life years and the relationship spelled out. Server-renderable.
25
+ */
26
+ export declare function AhnentafelList(props: AhnentafelListProps): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { lifeLabel, lifeSpan } from '@captiveorigins/onepedigree-core';
3
+ import { DEFAULT_TERMS, labelOf } from './describe.js';
4
+ /** "Father’s mother" for Ahnentafel number 6: each bit after the first is a step (0 = paternal, 1 = maternal). */
5
+ export function ahnentafelRelation(n, words = { paternal: 'Father', maternal: 'Mother' }) {
6
+ if (n <= 1)
7
+ return '';
8
+ const steps = n
9
+ .toString(2)
10
+ .slice(1)
11
+ .split('')
12
+ .map((b) => (b === '0' ? words.paternal : words.maternal));
13
+ return steps.map((w, i) => (i === 0 ? w : w.toLowerCase())).join('’s ');
14
+ }
15
+ /**
16
+ * The Ahnentafel as genealogists use it: a numbered list of ancestors by
17
+ * generation (1 = the focal individual, 2n = father, 2n + 1 = mother), with
18
+ * life years and the relationship spelled out. Server-renderable.
19
+ */
20
+ export function AhnentafelList(props) {
21
+ const terms = { ...DEFAULT_TERMS, ...props.terminology };
22
+ const rows = props.projection.occurrences.filter((o) => o.ahnentafel !== undefined).sort((a, b) => a.ahnentafel - b.ahnentafel);
23
+ const generations = new Map();
24
+ for (const r of rows) {
25
+ const g = Math.floor(Math.log2(r.ahnentafel)) + 1;
26
+ generations.set(g, [...(generations.get(g) ?? []), r]);
27
+ }
28
+ const unnumbered = props.projection.occurrences.filter((o) => o.ahnentafel === undefined);
29
+ return (_jsxs("div", { className: ['op-ahnentafel', props.className].filter(Boolean).join(' '), children: [[...generations].map(([g, list]) => (_jsxs("section", { className: "op-ahnentafel-generation", "aria-label": `Generation ${g}`, children: [_jsxs("h3", { children: ["Generation ", g] }), _jsx("ol", { children: list.map((o) => {
30
+ const individual = props.projection.individuals[o.individualId];
31
+ const href = individual ? props.getIndividualHref?.(individual) : null;
32
+ const name = labelOf(individual, terms);
33
+ const years = lifeLabel(lifeSpan(individual));
34
+ return (_jsxs("li", { value: o.ahnentafel, "data-selected": props.selectedIndividualId === o.individualId || undefined, children: [_jsxs("span", { className: "op-ahnentafel-number", children: [o.ahnentafel, "."] }), ' ', href ? (_jsx("a", { href: href, children: name })) : props.onSelect ? (_jsx("button", { type: "button", onClick: () => props.onSelect(o.individualId), children: name })) : (_jsx("span", { children: name })), years && _jsxs("span", { className: "op-ahnentafel-years", children: [" ", years] }), o.ahnentafel > 1 && _jsxs("span", { className: "op-ahnentafel-relation", children: [" \u00B7 ", ahnentafelRelation(o.ahnentafel, props.parentWords)] })] }, o.occurrenceId));
35
+ }) })] }, g))), unnumbered.length > 0 && (_jsxs("p", { className: "op-ahnentafel-note", children: [unnumbered.length, " ", unnumbered.length === 1 ? terms.individual : terms.individuals, " without a mother/father role are not numbered."] }))] }));
36
+ }
@@ -0,0 +1,47 @@
1
+ import { type Individual, type IndividualOccurrence } from '@captiveorigins/onepedigree-core';
2
+ import { type ReactNode } from 'react';
3
+ import { type PedigreeViewProps } from './PedigreeView.js';
4
+ import { type PedigreeTheme } from './themes.js';
5
+ export type QuickViewProps = {
6
+ individual: Individual | undefined;
7
+ occurrence: IndividualOccurrence;
8
+ href: string | null;
9
+ close: () => void;
10
+ };
11
+ export type PedigreeExplorerProps = Omit<PedigreeViewProps, 'interactive' | 'viewBox' | 'selectedIndividualId' | 'highlightedOccurrenceIds' | 'onNodeClick' | 'onNodeKeyDown'> & {
12
+ /** Height of the viewport (CSS length). Default 32rem. */
13
+ height?: string;
14
+ /** Replace the default quick-view card. */
15
+ renderQuickView?: (props: QuickViewProps) => ReactNode;
16
+ onSelectIndividual?: (individualId: string | null) => void;
17
+ /** Extra toolbar content (e.g. a view switcher). */
18
+ toolbar?: ReactNode;
19
+ /** Show the Colors button: pick a preset and adjust colors with hex codes. */
20
+ themeEditor?: boolean;
21
+ /** Called with the edited theme (e.g. to save it). */
22
+ onThemeChange?: (theme: PedigreeTheme) => void;
23
+ /** Print the chart (default: the browser's print, showing only the chart). Desktop shells can supply their own. */
24
+ onPrint?: () => void;
25
+ /** Ahnentafel words for the two parents (default Father/Mother). */
26
+ parentWords?: {
27
+ paternal: string;
28
+ maternal: string;
29
+ };
30
+ /**
31
+ * Turns on click-and-drag rearranging in views where order is a choice
32
+ * (REORDERABLE_VIEWS: descendants, hourglass, cohort, population, relationship).
33
+ * Called with the new order; pass it back as `layoutSync(projection, { nodeOrder })`.
34
+ */
35
+ onNodeOrderChange?: (nodeOrder: string[]) => void;
36
+ /** The current chosen order (what you passed to the layout), so a drag extends it. */
37
+ nodeOrder?: readonly string[];
38
+ /** Accessible labels for the controls. */
39
+ labels?: Partial<Record<'zoomIn' | 'zoomOut' | 'fit' | 'download' | 'print' | 'close' | 'open' | 'controls' | 'colors', string>>;
40
+ };
41
+ /**
42
+ * Interactive pedigree: pan (drag, Shift+arrows), zoom (wheel, +/-, buttons),
43
+ * fit, selection with highlighting of every occurrence of the selected
44
+ * individual, arrow-key navigation between nodes, a quick-view card, SVG
45
+ * download and print. Starts from the same markup as the server render.
46
+ */
47
+ export declare function PedigreeExplorer(props: PedigreeExplorerProps): import("react").JSX.Element;
@@ -0,0 +1,244 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { REORDERABLE_VIEWS } from '@captiveorigins/onepedigree-core';
4
+ import { useCallback, useMemo, useRef, useState } from 'react';
5
+ import { AhnentafelList } from './AhnentafelList.js';
6
+ import { DEFAULT_TERMS, describeStructure, labelOf } from './describe.js';
7
+ import { nearestNode, useOccurrenceHighlighting, usePedigreeSelection } from './hooks.js';
8
+ import { PedigreeView } from './PedigreeView.js';
9
+ import { GeneLegend, GenotypeKey } from './genetics.js';
10
+ import { Icon } from './icons.js';
11
+ import { dropNodeOrder } from './reorder.js';
12
+ import { ThemeEditor } from './ThemeEditor.js';
13
+ import { presetTheme, THEME_PRESETS, themeStyle } from './themes.js';
14
+ const LABELS = {
15
+ zoomIn: 'Zoom in',
16
+ zoomOut: 'Zoom out',
17
+ fit: 'Fit to view',
18
+ download: 'Download SVG',
19
+ print: 'Print',
20
+ close: 'Close',
21
+ open: 'Open profile',
22
+ controls: 'Pedigree controls',
23
+ colors: 'Colors',
24
+ };
25
+ /** Properties copied into downloaded SVGs (they have no stylesheet). */
26
+ const INLINE = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-opacity', 'opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'visibility', 'display'];
27
+ /** Prints only this chart: everything else on the page is hidden while printing. */
28
+ function printChart(el) {
29
+ if (!el)
30
+ return window.print();
31
+ el.setAttribute('data-op-printing', '');
32
+ document.documentElement.setAttribute('data-op-printing', '');
33
+ const done = () => {
34
+ el.removeAttribute('data-op-printing');
35
+ document.documentElement.removeAttribute('data-op-printing');
36
+ window.removeEventListener('afterprint', done);
37
+ };
38
+ window.addEventListener('afterprint', done);
39
+ window.print();
40
+ }
41
+ const KEY_DIRECTION = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
42
+ /**
43
+ * Interactive pedigree: pan (drag, Shift+arrows), zoom (wheel, +/-, buttons),
44
+ * fit, selection with highlighting of every occurrence of the selected
45
+ * individual, arrow-key navigation between nodes, a quick-view card, SVG
46
+ * download and print. Starts from the same markup as the server render.
47
+ */
48
+ export function PedigreeExplorer(props) {
49
+ const { projection, layout } = props;
50
+ const labels = { ...LABELS, ...props.labels };
51
+ const terms = { ...DEFAULT_TERMS, ...props.terminology };
52
+ const pad = props.padding ?? 24;
53
+ const full = useMemo(() => ({ x: -pad, y: -pad, width: layout.bounds.width + 2 * pad, height: layout.bounds.height + 2 * pad }), [layout, pad]);
54
+ const [box, setBox] = useState(full);
55
+ const [fitted, setFitted] = useState(full);
56
+ if (fitted !== full) {
57
+ setFitted(full);
58
+ setBox(full);
59
+ }
60
+ const { selectedId, select } = usePedigreeSelection();
61
+ const [selectedOccurrence, setSelectedOccurrence] = useState(null);
62
+ const highlighted = useOccurrenceHighlighting(projection, selectedId);
63
+ const root = useRef(null);
64
+ const initialTheme = typeof props.theme === 'string' ? presetTheme(props.theme) : props.theme;
65
+ const [theme, setTheme] = useState(initialTheme);
66
+ const [editing, setEditing] = useState(false);
67
+ const changeTheme = (t) => {
68
+ setTheme(t);
69
+ props.onThemeChange?.(t);
70
+ };
71
+ const drag = useRef(null);
72
+ // Dragging a node (rearranging) moves the element directly while the pointer is down: no re-render per frame.
73
+ const nodeDrag = useRef(null);
74
+ const suppressClick = useRef(false);
75
+ const reorderable = Boolean(props.onNodeOrderChange) && REORDERABLE_VIEWS.has(projection.view);
76
+ const choose = useCallback((occ) => {
77
+ select(occ?.individualId ?? null);
78
+ setSelectedOccurrence(occ?.occurrenceId ?? null);
79
+ props.onSelectIndividual?.(occ?.individualId ?? null);
80
+ },
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ [select, props.onSelectIndividual]);
83
+ const zoom = useCallback((factor, at) => {
84
+ setBox((b) => {
85
+ const width = Math.min(Math.max(b.width * factor, 80), full.width * 8);
86
+ const height = (b.height * width) / b.width;
87
+ const fx = at ? (at.x - b.x) / b.width : 0.5;
88
+ const fy = at ? (at.y - b.y) / b.height : 0.5;
89
+ return { x: b.x + (b.width - width) * fx, y: b.y + (b.height - height) * fy, width, height };
90
+ });
91
+ }, [full]);
92
+ const pan = (dx, dy) => setBox((b) => ({ ...b, x: b.x + dx * b.width, y: b.y + dy * b.height }));
93
+ const svgPoint = (e) => {
94
+ const svg = root.current?.querySelector('svg.op-root');
95
+ const r = svg?.getBoundingClientRect();
96
+ if (!r || !r.width || !r.height)
97
+ return undefined;
98
+ return { x: box.x + ((e.clientX - r.left) / r.width) * box.width, y: box.y + ((e.clientY - r.top) / r.height) * box.height };
99
+ };
100
+ const focusOccurrence = (occurrenceId) => {
101
+ const el = root.current?.querySelector(`[data-occurrence="${CSS.escape(occurrenceId)}"]`);
102
+ el?.focus();
103
+ };
104
+ const onNodeKeyDown = (occ, e) => {
105
+ if (e.key === 'Enter' || e.key === ' ') {
106
+ e.preventDefault();
107
+ choose(selectedId === occ.individualId && selectedOccurrence === occ.occurrenceId ? null : occ);
108
+ return;
109
+ }
110
+ const direction = KEY_DIRECTION[e.key];
111
+ if (direction && !e.shiftKey) {
112
+ e.preventDefault();
113
+ const next = nearestNode(layout, occ.occurrenceId, direction);
114
+ if (next)
115
+ focusOccurrence(next);
116
+ }
117
+ };
118
+ const onKeyDown = (e) => {
119
+ if (e.key === 'Escape')
120
+ choose(null);
121
+ else if (e.key === '+' || e.key === '=')
122
+ zoom(0.8);
123
+ else if (e.key === '-')
124
+ zoom(1.25);
125
+ else if (e.key === '0')
126
+ setBox(full);
127
+ else if (e.shiftKey && KEY_DIRECTION[e.key]) {
128
+ e.preventDefault();
129
+ const d = KEY_DIRECTION[e.key];
130
+ pan(d === 'left' ? -0.1 : d === 'right' ? 0.1 : 0, d === 'up' ? -0.1 : d === 'down' ? 0.1 : 0);
131
+ }
132
+ };
133
+ const onWheel = (e) => {
134
+ // Zoom only with Ctrl/⌘ (and trackpad pinch, which sets ctrlKey); plain scrolling scrolls the page.
135
+ if (!e.ctrlKey && !e.metaKey)
136
+ return;
137
+ zoom(e.deltaY > 0 ? 1.1 : 0.9, svgPoint(e));
138
+ };
139
+ const svgScale = () => {
140
+ const r = root.current?.querySelector('svg.op-root')?.getBoundingClientRect();
141
+ return r?.width ? Math.max(box.width / r.width, box.height / r.height) : 1;
142
+ };
143
+ const onPointerDown = (e) => {
144
+ const target = e.target;
145
+ if (target.closest('button, a, .op-toolbar, .op-quick-view, .op-theme-panel'))
146
+ return;
147
+ const nodeEl = target.closest('.op-node');
148
+ if (nodeEl) {
149
+ const id = nodeEl.getAttribute('data-occurrence');
150
+ if (reorderable && id)
151
+ nodeDrag.current = { x: e.clientX, y: e.clientY, id, el: nodeEl, moved: false };
152
+ return;
153
+ }
154
+ drag.current = { x: e.clientX, y: e.clientY, box };
155
+ e.currentTarget.setPointerCapture?.(e.pointerId);
156
+ };
157
+ const onPointerMove = (e) => {
158
+ const n = nodeDrag.current;
159
+ if (n) {
160
+ if (!n.moved && Math.hypot(e.clientX - n.x, e.clientY - n.y) < 4)
161
+ return;
162
+ if (!n.moved)
163
+ e.currentTarget.setPointerCapture?.(e.pointerId);
164
+ n.moved = true;
165
+ const k = svgScale();
166
+ n.el.style.translate = `${(e.clientX - n.x) * k}px ${(e.clientY - n.y) * k}px`;
167
+ n.el.setAttribute('data-dragging', '');
168
+ return;
169
+ }
170
+ const d = drag.current;
171
+ const r = root.current?.querySelector('svg.op-root')?.getBoundingClientRect();
172
+ if (!d || !r?.width)
173
+ return;
174
+ setBox({ ...d.box, x: d.box.x - ((e.clientX - d.x) / r.width) * d.box.width, y: d.box.y - ((e.clientY - d.y) / r.height) * d.box.height });
175
+ };
176
+ const onPointerUp = (e) => {
177
+ drag.current = null;
178
+ const n = nodeDrag.current;
179
+ nodeDrag.current = null;
180
+ if (!n?.moved)
181
+ return;
182
+ n.el.style.translate = '';
183
+ n.el.removeAttribute('data-dragging');
184
+ suppressClick.current = true;
185
+ const k = svgScale();
186
+ // Only among nodes in the same drawn groups (clutch/litter frames, other-parent groups) and with the same
187
+ // parents, so frames and family lines stay truthful.
188
+ const groupOf = (id) => [
189
+ ...(projection.groups ?? []).filter((g) => g.occurrenceIds.includes(id)).map((g) => g.id),
190
+ projection.edges.filter((ed) => ed.target === id).map((ed) => ed.source).sort().join('+'),
191
+ ].join('|');
192
+ const order = dropNodeOrder(layout, n.id, { dx: (e.clientX - n.x) * k, dy: (e.clientY - n.y) * k }, props.nodeOrder, groupOf);
193
+ if (order)
194
+ props.onNodeOrderChange(order);
195
+ };
196
+ const onPointerCancel = () => {
197
+ drag.current = null;
198
+ if (nodeDrag.current) {
199
+ nodeDrag.current.el.style.translate = '';
200
+ nodeDrag.current.el.removeAttribute('data-dragging');
201
+ }
202
+ nodeDrag.current = null;
203
+ };
204
+ const download = () => {
205
+ const svg = root.current?.querySelector('svg.op-root');
206
+ if (!svg)
207
+ return;
208
+ const clone = svg.cloneNode(true);
209
+ clone.setAttribute('viewBox', `${full.x} ${full.y} ${full.width} ${full.height}`);
210
+ clone.setAttribute('width', String(full.width));
211
+ clone.setAttribute('height', String(full.height));
212
+ // A downloaded file has no stylesheet: copy each element's computed colors and fonts into it.
213
+ const source = [svg, ...svg.querySelectorAll('*')];
214
+ const target = [clone, ...clone.querySelectorAll('*')];
215
+ source.forEach((el, i) => {
216
+ const cs = getComputedStyle(el);
217
+ target[i]?.setAttribute('style', INLINE.map((prop) => `${prop}:${cs.getPropertyValue(prop)}`).join(';'));
218
+ });
219
+ const bg = getComputedStyle(root.current).backgroundColor;
220
+ if (bg && bg !== 'rgba(0, 0, 0, 0)') {
221
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
222
+ for (const [k, v] of Object.entries({ x: full.x, y: full.y, width: full.width, height: full.height, fill: bg }))
223
+ rect.setAttribute(k, String(v));
224
+ clone.insertBefore(rect, clone.firstChild);
225
+ }
226
+ const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml' });
227
+ const a = document.createElement('a');
228
+ a.href = URL.createObjectURL(blob);
229
+ a.download = `${projection.view}-${projection.focalId}.svg`;
230
+ a.click();
231
+ URL.revokeObjectURL(a.href);
232
+ };
233
+ const occurrence = projection.occurrences.find((o) => o.occurrenceId === selectedOccurrence);
234
+ const individual = occurrence ? projection.individuals[occurrence.individualId] : undefined;
235
+ const href = individual ? (props.getIndividualHref?.(individual) ?? null) : null;
236
+ const close = () => choose(null);
237
+ return (_jsxs("div", { ref: root, className: "op-explorer", "data-legend": props.geneOverlay || props.genotypes ? '' : undefined, style: { height: props.height ?? '32rem', ...(theme ? { ...themeStyle(theme, projection.view), background: 'var(--op-background)' } : {}) }, onKeyDown: onKeyDown, onWheel: onWheel, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onClickCapture: (e) => {
238
+ // A drag ends with a click on the node: don't let it select.
239
+ if (suppressClick.current) {
240
+ suppressClick.current = false;
241
+ e.stopPropagation();
242
+ }
243
+ }, "data-reorderable": reorderable ? '' : undefined, children: [_jsxs("div", { className: "op-toolbar", role: "toolbar", "aria-label": labels.controls, children: [_jsx("button", { type: "button", onClick: () => zoom(0.8), "aria-label": labels.zoomIn, "data-tip": labels.zoomIn, children: _jsx(Icon, { name: "zoomIn" }) }), _jsx("button", { type: "button", onClick: () => zoom(1.25), "aria-label": labels.zoomOut, "data-tip": labels.zoomOut, children: _jsx(Icon, { name: "zoomOut" }) }), _jsx("button", { type: "button", onClick: () => setBox(full), "aria-label": labels.fit, "data-tip": labels.fit, children: _jsx(Icon, { name: "fit" }) }), _jsx("button", { type: "button", onClick: download, "aria-label": labels.download, "data-tip": labels.download, children: _jsx(Icon, { name: "download" }) }), _jsx("button", { type: "button", onClick: () => (props.onPrint ? props.onPrint() : printChart(root.current)), "aria-label": labels.print, "data-tip": labels.print, children: _jsx(Icon, { name: "print" }) }), props.themeEditor && (_jsx("button", { type: "button", onClick: () => setEditing((v) => !v), "aria-label": labels.colors, "data-tip": labels.colors, "aria-expanded": editing, children: _jsx(Icon, { name: "palette" }) })), props.toolbar] }), props.themeEditor && editing && (_jsx("div", { className: "op-theme-panel", children: _jsx(ThemeEditor, { value: theme ?? structuredClone(THEME_PRESETS[0]), view: projection.view, onChange: changeTheme }) })), projection.view === 'ahnentafel' ? (_jsx(AhnentafelList, { projection: projection, terminology: props.terminology, parentWords: props.parentWords, getIndividualHref: props.getIndividualHref, selectedIndividualId: selectedId, onSelect: (individualId) => choose(projection.occurrences.find((o) => o.individualId === individualId) ?? null) })) : (_jsx(PedigreeView, { ...props, theme: theme, interactive: true, viewBox: box, selectedIndividualId: selectedId, highlightedOccurrenceIds: highlighted, onNodeClick: (occ) => choose(selectedOccurrence === occ.occurrenceId ? null : occ), onNodeKeyDown: onNodeKeyDown, svgProps: { ...props.svgProps, width: '100%', height: '100%', preserveAspectRatio: 'xMidYMid meet' } })), occurrence && (_jsx("div", { className: "op-quick-view", role: "dialog", "aria-label": labelOf(individual, terms), children: props.renderQuickView ? (props.renderQuickView({ individual, occurrence, href, close })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "op-quick-view-title", children: labelOf(individual, terms) }), individual?.sex && _jsx("p", { children: individual.sex }), _jsx("p", { children: describeStructure(projection.structures[occurrence.individualId]) }), occurrence.repeated && _jsx("p", { children: terms.repeated }), _jsxs("div", { className: "op-quick-view-actions", children: [href && (_jsxs("a", { href: href, children: [_jsx(Icon, { name: "open" }), labels.open] })), _jsxs("button", { type: "button", onClick: close, children: [_jsx(Icon, { name: "close" }), labels.close] })] })] })) })), (props.geneOverlay || props.genotypes) && (_jsxs("div", { className: "op-gene-legends", children: [props.genotypes && _jsx(GenotypeKey, { display: props.genotypes }), props.geneOverlay && _jsx(GeneLegend, { overlay: props.geneOverlay })] }))] }));
244
+ }
@@ -0,0 +1,71 @@
1
+ import type { Individual, IndividualOccurrence, LayoutResult, PedigreeViewProjection, PositionedNode, ProjectionGroup, ReproductiveStructure } from '@captiveorigins/onepedigree-core';
2
+ import type { KeyboardEvent, MouseEvent, ReactNode, SVGProps } from 'react';
3
+ import { type Terminology } from './describe.js';
4
+ import { type PedigreeTheme } from './themes.js';
5
+ import { type GenotypeDisplay, type GenotypeMark, type GeneOverlay } from './genetics.js';
6
+ export type IndividualSlotProps = {
7
+ individual: Individual | undefined;
8
+ occurrence: IndividualOccurrence;
9
+ node: PositionedNode;
10
+ structure: ReproductiveStructure | undefined;
11
+ selected: boolean;
12
+ highlighted: boolean;
13
+ terms: Terminology;
14
+ /** The shown gene's mark for this individual, when a gene overlay is on (null = unknown). */
15
+ gene?: GenotypeMark | null;
16
+ /** Allele dots for several genes, when `genotypes` is on. */
17
+ genotypes?: GenotypeDisplay;
18
+ };
19
+ export type CohortMarkerSlotProps = {
20
+ group: ProjectionGroup;
21
+ box: {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ };
27
+ };
28
+ export type PedigreeClassNames = Partial<Record<'root' | 'node' | 'nodeBox' | 'label' | 'connector' | 'cohort' | 'fanSegment', string>>;
29
+ export type PedigreeViewProps = {
30
+ projection: PedigreeViewProjection;
31
+ layout: LayoutResult;
32
+ /** Accessible title (default: the view name and focal individual). */
33
+ title?: string;
34
+ className?: string;
35
+ classNames?: PedigreeClassNames;
36
+ terminology?: Partial<Terminology>;
37
+ /** Profile URL for an individual (server-resolved). Nodes become ordinary links. */
38
+ getIndividualHref?: (individual: Individual) => string | null | undefined;
39
+ /** Replace the default node content (SVG, drawn in the node's box at 0,0). */
40
+ renderIndividual?: (props: IndividualSlotProps) => ReactNode;
41
+ renderCohortMarker?: (props: CohortMarkerSlotProps) => ReactNode;
42
+ selectedIndividualId?: string | null;
43
+ highlightedOccurrenceIds?: ReadonlySet<string>;
44
+ /** Interactive explorer: nodes are focusable buttons. */
45
+ interactive?: boolean;
46
+ onNodeClick?: (occurrence: IndividualOccurrence, event: MouseEvent) => void;
47
+ onNodeKeyDown?: (occurrence: IndividualOccurrence, event: KeyboardEvent) => void;
48
+ /** Visible region (pan/zoom); default: the whole layout. */
49
+ viewBox?: {
50
+ x: number;
51
+ y: number;
52
+ width: number;
53
+ height: number;
54
+ };
55
+ padding?: number;
56
+ /** Unique prefix for element IDs when several views share a page. */
57
+ idPrefix?: string;
58
+ svgProps?: Omit<SVGProps<SVGSVGElement>, 'viewBox' | 'children'>;
59
+ /** Show several genes on every node as allele dots (two per gene, one per copy). */
60
+ genotypes?: GenotypeDisplay;
61
+ /** Show one heritable gene on every node (genetic pedigree symbols; see GeneLegend). */
62
+ geneOverlay?: GeneOverlay;
63
+ /** A preset id ('classic', 'midnight', 'forest', 'ocean', 'terracotta', 'high-contrast', 'print') or a custom theme. */
64
+ theme?: PedigreeTheme | string;
65
+ };
66
+ /**
67
+ * A pedigree view as SVG. Renders on the server with no client JavaScript;
68
+ * links stay ordinary links. Every node carries stable `.op-*` classes and
69
+ * `data-*` attributes for styling; color is never the only signal.
70
+ */
71
+ export declare function PedigreeView(props: PedigreeViewProps): import("react").JSX.Element;