@captiveorigins/onepedigree-react 0.0.0-stage → 0.1.0-alpha.10
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 +21 -0
- package/README.md +87 -2
- package/dist/AhnentafelList.d.ts +26 -0
- package/dist/AhnentafelList.js +36 -0
- package/dist/PedigreeExplorer.d.ts +47 -0
- package/dist/PedigreeExplorer.js +244 -0
- package/dist/PedigreeView.d.ts +71 -0
- package/dist/PedigreeView.js +153 -0
- package/dist/ThemeEditor.d.ts +12 -0
- package/dist/ThemeEditor.js +50 -0
- package/dist/describe.d.ts +27 -0
- package/dist/describe.js +75 -0
- package/dist/genetics.d.ts +66 -0
- package/dist/genetics.js +96 -0
- package/dist/headless.d.ts +3 -0
- package/dist/headless.js +3 -0
- package/dist/hooks.d.ts +21 -0
- package/dist/hooks.js +68 -0
- package/dist/icons.d.ts +76 -0
- package/dist/icons.js +74 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/reorder.d.ts +14 -0
- package/dist/reorder.js +28 -0
- package/dist/styles.css +171 -0
- package/dist/themes.d.ts +24 -0
- package/dist/themes.js +216 -0
- package/package.json +64 -3
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
|
-
#
|
|
1
|
+
# @captiveorigins/onepedigree-react
|
|
2
2
|
|
|
3
|
-
|
|
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", 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;
|