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

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,71 @@
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
+ ## Themes and colors
30
+
31
+ Seven presets, each with its own per-view accents (e.g. warm maternal and cool paternal
32
+ lines, distinct highlights for the relationship and inbreeding views, fan ring shading):
33
+ `classic`, `midnight`, `forest`, `ocean`, `terracotta`, `high-contrast`, `print`.
34
+
35
+ ```tsx
36
+ <PedigreeView projection={projection} layout={layout} theme="forest" />
37
+ <PedigreeExplorer projection={projection} layout={layout} theme="ocean" themeEditor onThemeChange={save} />
38
+ ```
39
+
40
+ `themeEditor` adds a Colors button: choose a preset and adjust any color with a color picker
41
+ or hex code, for all views or only the current one, with a warning when text contrast drops
42
+ below WCAG AA. `onThemeChange` receives the edited theme (a plain object) to store; pass it
43
+ back as `theme`. `ThemeEditor` is also exported for use in your own settings pages. Themes are
44
+ rendered as inline custom properties, so they work in server rendering.
45
+
46
+ ## Styling
47
+
48
+ - Default stylesheet `styles.css` (optional; leave it out for unstyled mode).
49
+ - CSS custom properties: `--op-node-background`, `--op-node-border`, `--op-connector`,
50
+ `--op-selected-ring`, `--op-highlight`, `--op-focal-border`, `--op-label`, …
51
+ - Stable classes (`.op-root`, `.op-node`, `.op-individual`, `.op-connector`, `.op-junction`,
52
+ `.op-cohort`, `.op-fan-segment`) and data attributes (`data-individual`, `data-occurrence`,
53
+ `data-repeated`, `data-generation`, `data-cohort`, `data-reproductive-mode`, `data-sex`,
54
+ `data-focal`, `data-selected`, `data-highlighted`).
55
+ - `classNames` overrides, and render slots `renderIndividual`, `renderCohortMarker`,
56
+ `renderQuickView`.
57
+ - Headless: `@captiveorigins/onepedigree-react/headless` (hooks `usePedigreeProjection`,
58
+ `usePedigreeLayout`, `usePedigreeSelection`, `usePedigreeNavigation`,
59
+ `useOccurrenceHighlighting`, and `describeProjection`) for your own renderer.
60
+
61
+ ## Accessibility
62
+
63
+ Semantic SVG with `<title>`/`<desc>` summaries, accessible names per node, keyboard
64
+ navigation (arrows between nodes, Enter to select, Escape to clear, +/−/0 to zoom, Shift+arrows
65
+ to pan), visible focus, reduced-motion support, and non-color cues: junction shapes (circle =
66
+ two-parent mode, diamond = single-parent mode, square = variable; hollow = incomplete), line
67
+ patterns per reproductive mode, and a ↻ marker plus dashed border on repeated individuals.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,32 @@
1
+ import type { Individual, 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
+ /** Accessible labels for the controls. */
24
+ labels?: Partial<Record<'zoomIn' | 'zoomOut' | 'fit' | 'download' | 'print' | 'close' | 'open' | 'controls' | 'colors', string>>;
25
+ };
26
+ /**
27
+ * Interactive pedigree: pan (drag, Shift+arrows), zoom (wheel, +/-, buttons),
28
+ * fit, selection with highlighting of every occurrence of the selected
29
+ * individual, arrow-key navigation between nodes, a quick-view card, SVG
30
+ * download and print. Starts from the same markup as the server render.
31
+ */
32
+ export declare function PedigreeExplorer(props: PedigreeExplorerProps): import("react").JSX.Element;
@@ -0,0 +1,148 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { DEFAULT_TERMS, describeStructure, labelOf } from './describe.js';
5
+ import { nearestNode, useOccurrenceHighlighting, usePedigreeSelection } from './hooks.js';
6
+ import { PedigreeView } from './PedigreeView.js';
7
+ import { ThemeEditor } from './ThemeEditor.js';
8
+ import { presetTheme, THEME_PRESETS, themeStyle } from './themes.js';
9
+ const LABELS = {
10
+ zoomIn: 'Zoom in',
11
+ zoomOut: 'Zoom out',
12
+ fit: 'Fit to view',
13
+ download: 'Download SVG',
14
+ print: 'Print',
15
+ close: 'Close',
16
+ open: 'Open profile',
17
+ controls: 'Pedigree controls',
18
+ colors: 'Colors',
19
+ };
20
+ const KEY_DIRECTION = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
21
+ /**
22
+ * Interactive pedigree: pan (drag, Shift+arrows), zoom (wheel, +/-, buttons),
23
+ * fit, selection with highlighting of every occurrence of the selected
24
+ * individual, arrow-key navigation between nodes, a quick-view card, SVG
25
+ * download and print. Starts from the same markup as the server render.
26
+ */
27
+ export function PedigreeExplorer(props) {
28
+ const { projection, layout } = props;
29
+ const labels = { ...LABELS, ...props.labels };
30
+ const terms = { ...DEFAULT_TERMS, ...props.terminology };
31
+ const pad = props.padding ?? 24;
32
+ const full = useMemo(() => ({ x: -pad, y: -pad, width: layout.bounds.width + 2 * pad, height: layout.bounds.height + 2 * pad }), [layout, pad]);
33
+ const [box, setBox] = useState(full);
34
+ const [fitted, setFitted] = useState(full);
35
+ if (fitted !== full) {
36
+ setFitted(full);
37
+ setBox(full);
38
+ }
39
+ const { selectedId, select } = usePedigreeSelection();
40
+ const [selectedOccurrence, setSelectedOccurrence] = useState(null);
41
+ const highlighted = useOccurrenceHighlighting(projection, selectedId);
42
+ const root = useRef(null);
43
+ const initialTheme = typeof props.theme === 'string' ? presetTheme(props.theme) : props.theme;
44
+ const [theme, setTheme] = useState(initialTheme);
45
+ const [editing, setEditing] = useState(false);
46
+ const changeTheme = (t) => {
47
+ setTheme(t);
48
+ props.onThemeChange?.(t);
49
+ };
50
+ const drag = useRef(null);
51
+ const choose = useCallback((occ) => {
52
+ select(occ?.individualId ?? null);
53
+ setSelectedOccurrence(occ?.occurrenceId ?? null);
54
+ props.onSelectIndividual?.(occ?.individualId ?? null);
55
+ },
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ [select, props.onSelectIndividual]);
58
+ const zoom = useCallback((factor, at) => {
59
+ setBox((b) => {
60
+ const width = Math.min(Math.max(b.width * factor, 80), full.width * 8);
61
+ const height = (b.height * width) / b.width;
62
+ const fx = at ? (at.x - b.x) / b.width : 0.5;
63
+ const fy = at ? (at.y - b.y) / b.height : 0.5;
64
+ return { x: b.x + (b.width - width) * fx, y: b.y + (b.height - height) * fy, width, height };
65
+ });
66
+ }, [full]);
67
+ const pan = (dx, dy) => setBox((b) => ({ ...b, x: b.x + dx * b.width, y: b.y + dy * b.height }));
68
+ const svgPoint = (e) => {
69
+ const svg = root.current?.querySelector('svg');
70
+ const r = svg?.getBoundingClientRect();
71
+ if (!r || !r.width || !r.height)
72
+ return undefined;
73
+ return { x: box.x + ((e.clientX - r.left) / r.width) * box.width, y: box.y + ((e.clientY - r.top) / r.height) * box.height };
74
+ };
75
+ const focusOccurrence = (occurrenceId) => {
76
+ const el = root.current?.querySelector(`[data-occurrence="${CSS.escape(occurrenceId)}"]`);
77
+ el?.focus();
78
+ };
79
+ const onNodeKeyDown = (occ, e) => {
80
+ if (e.key === 'Enter' || e.key === ' ') {
81
+ e.preventDefault();
82
+ choose(selectedId === occ.individualId && selectedOccurrence === occ.occurrenceId ? null : occ);
83
+ return;
84
+ }
85
+ const direction = KEY_DIRECTION[e.key];
86
+ if (direction && !e.shiftKey) {
87
+ e.preventDefault();
88
+ const next = nearestNode(layout, occ.occurrenceId, direction);
89
+ if (next)
90
+ focusOccurrence(next);
91
+ }
92
+ };
93
+ const onKeyDown = (e) => {
94
+ if (e.key === 'Escape')
95
+ choose(null);
96
+ else if (e.key === '+' || e.key === '=')
97
+ zoom(0.8);
98
+ else if (e.key === '-')
99
+ zoom(1.25);
100
+ else if (e.key === '0')
101
+ setBox(full);
102
+ else if (e.shiftKey && KEY_DIRECTION[e.key]) {
103
+ e.preventDefault();
104
+ const d = KEY_DIRECTION[e.key];
105
+ pan(d === 'left' ? -0.1 : d === 'right' ? 0.1 : 0, d === 'up' ? -0.1 : d === 'down' ? 0.1 : 0);
106
+ }
107
+ };
108
+ const onWheel = (e) => {
109
+ // Zoom only with Ctrl/⌘ (and trackpad pinch, which sets ctrlKey); plain scrolling scrolls the page.
110
+ if (!e.ctrlKey && !e.metaKey)
111
+ return;
112
+ zoom(e.deltaY > 0 ? 1.1 : 0.9, svgPoint(e));
113
+ };
114
+ const onPointerDown = (e) => {
115
+ if (e.target.closest('.op-node, button, a'))
116
+ return;
117
+ drag.current = { x: e.clientX, y: e.clientY, box };
118
+ e.currentTarget.setPointerCapture?.(e.pointerId);
119
+ };
120
+ const onPointerMove = (e) => {
121
+ const d = drag.current;
122
+ const r = root.current?.querySelector('svg')?.getBoundingClientRect();
123
+ if (!d || !r?.width)
124
+ return;
125
+ 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 });
126
+ };
127
+ const onPointerUp = () => {
128
+ drag.current = null;
129
+ };
130
+ const download = () => {
131
+ const svg = root.current?.querySelector('svg');
132
+ if (!svg)
133
+ return;
134
+ const clone = svg.cloneNode(true);
135
+ clone.setAttribute('viewBox', `${full.x} ${full.y} ${full.width} ${full.height}`);
136
+ const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml' });
137
+ const a = document.createElement('a');
138
+ a.href = URL.createObjectURL(blob);
139
+ a.download = `${projection.view}-${projection.focalId}.svg`;
140
+ a.click();
141
+ URL.revokeObjectURL(a.href);
142
+ };
143
+ const occurrence = projection.occurrences.find((o) => o.occurrenceId === selectedOccurrence);
144
+ const individual = occurrence ? projection.individuals[occurrence.individualId] : undefined;
145
+ const href = individual ? (props.getIndividualHref?.(individual) ?? null) : null;
146
+ const close = () => choose(null);
147
+ 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: onPointerUp, 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, title: labels.zoomIn, children: "+" }), _jsx("button", { type: "button", onClick: () => zoom(1.25), "aria-label": labels.zoomOut, title: labels.zoomOut, children: "\u2212" }), _jsx("button", { type: "button", onClick: () => setBox(full), "aria-label": labels.fit, title: labels.fit, children: "\u2922" }), _jsx("button", { type: "button", onClick: download, "aria-label": labels.download, title: labels.download, children: "\u2913" }), _jsx("button", { type: "button", onClick: () => window.print(), "aria-label": labels.print, title: labels.print, children: "\u2399" }), props.themeEditor && (_jsx("button", { type: "button", onClick: () => setEditing((v) => !v), "aria-label": labels.colors, title: labels.colors, "aria-expanded": editing, children: "\u25D0" })), 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 }) })), _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 && _jsx("a", { href: href, children: labels.open }), _jsx("button", { type: "button", onClick: close, children: labels.close })] })] })) }))] }));
148
+ }
@@ -0,0 +1,62 @@
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
+ export type IndividualSlotProps = {
6
+ individual: Individual | undefined;
7
+ occurrence: IndividualOccurrence;
8
+ node: PositionedNode;
9
+ structure: ReproductiveStructure | undefined;
10
+ selected: boolean;
11
+ highlighted: boolean;
12
+ terms: Terminology;
13
+ };
14
+ export type CohortMarkerSlotProps = {
15
+ group: ProjectionGroup;
16
+ box: {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ };
22
+ };
23
+ export type PedigreeClassNames = Partial<Record<'root' | 'node' | 'nodeBox' | 'label' | 'connector' | 'cohort' | 'fanSegment', string>>;
24
+ export type PedigreeViewProps = {
25
+ projection: PedigreeViewProjection;
26
+ layout: LayoutResult;
27
+ /** Accessible title (default: the view name and focal individual). */
28
+ title?: string;
29
+ className?: string;
30
+ classNames?: PedigreeClassNames;
31
+ terminology?: Partial<Terminology>;
32
+ /** Profile URL for an individual (server-resolved). Nodes become ordinary links. */
33
+ getIndividualHref?: (individual: Individual) => string | null | undefined;
34
+ /** Replace the default node content (SVG, drawn in the node's box at 0,0). */
35
+ renderIndividual?: (props: IndividualSlotProps) => ReactNode;
36
+ renderCohortMarker?: (props: CohortMarkerSlotProps) => ReactNode;
37
+ selectedIndividualId?: string | null;
38
+ highlightedOccurrenceIds?: ReadonlySet<string>;
39
+ /** Interactive explorer: nodes are focusable buttons. */
40
+ interactive?: boolean;
41
+ onNodeClick?: (occurrence: IndividualOccurrence, event: MouseEvent) => void;
42
+ onNodeKeyDown?: (occurrence: IndividualOccurrence, event: KeyboardEvent) => void;
43
+ /** Visible region (pan/zoom); default: the whole layout. */
44
+ viewBox?: {
45
+ x: number;
46
+ y: number;
47
+ width: number;
48
+ height: number;
49
+ };
50
+ padding?: number;
51
+ /** Unique prefix for element IDs when several views share a page. */
52
+ idPrefix?: string;
53
+ svgProps?: Omit<SVGProps<SVGSVGElement>, 'viewBox' | 'children'>;
54
+ /** A preset id ('classic', 'midnight', 'forest', 'ocean', 'terracotta', 'high-contrast', 'print') or a custom theme. */
55
+ theme?: PedigreeTheme | string;
56
+ };
57
+ /**
58
+ * A pedigree view as SVG. Renders on the server with no client JavaScript;
59
+ * links stay ordinary links. Every node carries stable `.op-*` classes and
60
+ * `data-*` attributes for styling; color is never the only signal.
61
+ */
62
+ export declare function PedigreeView(props: PedigreeViewProps): import("react").JSX.Element;
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { arcPath, DEFAULT_TERMS, describeProjection, describeStructure, labelOf, VIEW_LABELS } from './describe.js';
3
+ import { themeStyle } from './themes.js';
4
+ const cx = (...c) => c.filter(Boolean).join(' ');
5
+ const short = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
6
+ /** Junction glyph: circle = two-parent mode, diamond = single-parent mode, square = variable/other; hollow = incomplete. */
7
+ function Junction({ s, x, y }) {
8
+ const shape = s.typicalStructure === 'pair' ? 'pair' : s.typicalStructure === 'single' ? 'single' : 'variable';
9
+ const common = {
10
+ className: 'op-junction',
11
+ 'data-structure': shape,
12
+ 'data-completeness': s.completeness,
13
+ 'data-atypical': s.atypical || undefined,
14
+ };
15
+ const r = 5;
16
+ const glyph = shape === 'pair' ? (_jsx("circle", { cx: x, cy: y, r: r, ...common })) : shape === 'single' ? (_jsx("path", { d: `M ${x} ${y - r - 1} L ${x + r + 1} ${y} L ${x} ${y + r + 1} L ${x - r - 1} ${y} Z`, ...common })) : (_jsx("rect", { x: x - r, y: y - r, width: 2 * r, height: 2 * r, ...common }));
17
+ return (_jsxs("g", { "aria-hidden": "true", children: [glyph, _jsx("title", { children: describeStructure(s) })] }));
18
+ }
19
+ function DefaultIndividual({ individual, occurrence, node, structure, terms }) {
20
+ const label = labelOf(individual, terms);
21
+ const sub = [individual?.sex, occurrence.ahnentafel !== undefined ? `#${occurrence.ahnentafel}` : null].filter(Boolean).join(' · ');
22
+ const chars = Math.max(6, Math.floor(node.width / 7.5));
23
+ return (_jsxs(_Fragment, { children: [_jsx("rect", { className: "op-node-box", width: node.width, height: node.height, rx: 8, ry: 8 }), _jsx("text", { className: "op-label", x: 10, y: sub ? node.height / 2 - 3 : node.height / 2 + 4, children: short(label, chars) }), sub && (_jsx("text", { className: "op-sublabel", x: 10, y: node.height / 2 + 13, children: short(sub, chars) })), occurrence.repeated && (_jsx("text", { className: "op-repeat-marker", x: node.width - 8, y: 14, textAnchor: "end", children: "\u21BB" })), structure && _jsx(Junction, { s: structure, x: node.width / 2, y: 0 })] }));
24
+ }
25
+ function groupBox(group, nodes, pad = 10) {
26
+ const ns = group.occurrenceIds.map((id) => nodes.get(id)).filter((n) => Boolean(n));
27
+ if (!ns.length)
28
+ return null;
29
+ const x = Math.min(...ns.map((n) => n.x)) - pad;
30
+ const y = Math.min(...ns.map((n) => n.y)) - pad;
31
+ return { x, y, width: Math.max(...ns.map((n) => n.x + n.width)) - x + pad, height: Math.max(...ns.map((n) => n.y + n.height)) - y + pad };
32
+ }
33
+ /**
34
+ * A pedigree view as SVG. Renders on the server with no client JavaScript;
35
+ * links stay ordinary links. Every node carries stable `.op-*` classes and
36
+ * `data-*` attributes for styling; color is never the only signal.
37
+ */
38
+ export function PedigreeView(props) {
39
+ const { projection: p, layout, interactive } = props;
40
+ const terms = { ...DEFAULT_TERMS, ...props.terminology };
41
+ const pad = props.padding ?? 24;
42
+ const id = props.idPrefix ?? `op-${p.view}-${p.focalId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
43
+ const box = props.viewBox ?? { x: -pad, y: -pad, width: layout.bounds.width + 2 * pad, height: layout.bounds.height + 2 * pad };
44
+ const nodes = new Map(layout.nodes.map((n) => [n.occurrenceId, n]));
45
+ const occById = new Map(p.occurrences.map((o) => [o.occurrenceId, o]));
46
+ const title = props.title ?? `${VIEW_LABELS[p.view]}: ${labelOf(p.individuals[p.focalId], terms)}`;
47
+ const summary = describeProjection(p, terms);
48
+ const c = props.classNames ?? {};
49
+ return (_jsxs("svg", { ...props.svgProps, className: cx('op-root', `op-view-${p.view}`, props.className, c.root), style: { ...themeStyle(props.theme, p.view), ...props.svgProps?.style }, "data-theme": typeof props.theme === 'string' ? props.theme : props.theme?.id, viewBox: `${box.x} ${box.y} ${box.width} ${box.height}`, role: "group", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-desc`, "data-view": p.view, "data-orientation": layout.orientation, "data-occurrence-mode": p.occurrenceMode, xmlns: "http://www.w3.org/2000/svg", children: [_jsx("title", { id: `${id}-title`, children: title }), _jsx("desc", { id: `${id}-desc`, children: summary }), p.groups
50
+ .filter((g) => g.kind === 'cohort')
51
+ .map((g) => {
52
+ const b = groupBox(g, nodes);
53
+ if (!b)
54
+ return null;
55
+ return (_jsx("g", { className: cx('op-cohort', c.cohort), "data-cohort": g.id.replace(/^cohort:/, ''), children: props.renderCohortMarker ? (props.renderCohortMarker({ group: g, box: b })) : (_jsxs(_Fragment, { children: [_jsx("rect", { className: "op-cohort-box", x: b.x, y: b.y, width: b.width, height: b.height, rx: 12, ry: 12 }), _jsx("text", { className: "op-cohort-label", x: b.x + 10, y: b.y + b.height + 14, children: g.label ?? terms.cohort })] })) }, g.id));
56
+ }), _jsx("g", { className: "op-connectors", children: layout.edges.map((e) => {
57
+ const child = occById.get(e.target);
58
+ const s = child ? p.structures[child.individualId] : undefined;
59
+ const highlighted = props.highlightedOccurrenceIds?.has(e.source) && props.highlightedOccurrenceIds?.has(e.target);
60
+ return (_jsx("path", { className: cx('op-connector', c.connector), d: e.points.map((pt, i) => `${i ? 'L' : 'M'} ${pt.x} ${pt.y}`).join(' '), "data-role": e.role ?? undefined, "data-reproductive-mode": s?.reproductiveMode ?? undefined, "data-completeness": s?.completeness, "data-highlighted": highlighted || undefined }, e.id));
61
+ }) }), _jsx("g", { className: "op-nodes", children: layout.nodes.map((node) => {
62
+ const occurrence = occById.get(node.occurrenceId);
63
+ if (!occurrence)
64
+ return null;
65
+ const individual = p.individuals[occurrence.individualId];
66
+ const structure = p.structures[occurrence.individualId];
67
+ const selected = props.selectedIndividualId === occurrence.individualId;
68
+ const highlighted = Boolean(props.highlightedOccurrenceIds?.has(node.occurrenceId) || occurrence.highlighted);
69
+ const href = individual ? props.getIndividualHref?.(individual) : null;
70
+ const label = labelOf(individual, terms);
71
+ const aria = [
72
+ label,
73
+ individual?.sex,
74
+ occurrence.ahnentafel !== undefined ? `Ahnentafel ${occurrence.ahnentafel}` : null,
75
+ occurrence.repeated ? terms.repeated : null,
76
+ describeStructure(structure),
77
+ ]
78
+ .filter(Boolean)
79
+ .join(', ');
80
+ const slot = { individual, occurrence, node, structure, selected, highlighted, terms };
81
+ const content = node.arc ? (_jsxs(_Fragment, { children: [_jsx("path", { className: cx('op-fan-segment', c.fanSegment), d: arcPath(node.arc) }), _jsx("text", { className: "op-label", x: node.x + node.width / 2, y: node.y + node.height / 2 + 4, textAnchor: "middle", children: short(label, Math.max(4, Math.floor((node.arc.outerRadius - node.arc.innerRadius) / 7))) })] })) : (_jsx("g", { transform: `translate(${node.x} ${node.y})`, children: props.renderIndividual ? props.renderIndividual(slot) : _jsx(DefaultIndividual, { ...slot }) }));
82
+ const attrs = {
83
+ className: cx('op-node', 'op-individual', c.node),
84
+ 'data-individual': occurrence.individualId,
85
+ 'data-occurrence': occurrence.occurrenceId,
86
+ 'data-generation': occurrence.generation,
87
+ 'data-repeated': occurrence.repeated || undefined,
88
+ 'data-collapsed': occurrence.collapsedInto ? true : undefined,
89
+ 'data-cohort': individual?.parentage?.cohort ?? undefined,
90
+ 'data-reproductive-mode': structure?.reproductiveMode ?? undefined,
91
+ 'data-sex': individual?.sex ?? undefined,
92
+ 'data-focal': occurrence.individualId === p.focalId || undefined,
93
+ 'data-selected': selected || undefined,
94
+ 'data-highlighted': highlighted || undefined,
95
+ };
96
+ if (interactive)
97
+ return (_jsx("g", { ...attrs, role: "button", tabIndex: 0, "aria-label": aria, "aria-pressed": selected, onClick: (e) => props.onNodeClick?.(occurrence, e), onKeyDown: (e) => props.onNodeKeyDown?.(occurrence, e), children: content }, node.occurrenceId));
98
+ return (_jsx("g", { ...attrs, children: href ? (_jsx("a", { href: href, "aria-label": aria, children: content })) : (_jsxs(_Fragment, { children: [_jsx("title", { children: aria }), content] })) }, node.occurrenceId));
99
+ }) })] }));
100
+ }
@@ -0,0 +1,12 @@
1
+ import type { PedigreeViewId } from '@captiveorigins/onepedigree-core';
2
+ import { type PedigreeTheme } from './themes.js';
3
+ export type ThemeEditorProps = {
4
+ value: PedigreeTheme;
5
+ /** The view being shown (for "this view only" changes and the preview of its accents). */
6
+ view: PedigreeViewId;
7
+ onChange: (theme: PedigreeTheme) => void;
8
+ presets?: readonly PedigreeTheme[];
9
+ labels?: Partial<Record<'title' | 'preset' | 'scope' | 'allViews' | 'thisView' | 'reset' | 'lowContrast' | 'invalid', string>>;
10
+ };
11
+ /** Pick a preset and adjust any color with a color picker or hex code, for all views or the current one. */
12
+ export declare function ThemeEditor({ value, view, onChange, presets, labels: l }: ThemeEditorProps): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useState } from 'react';
4
+ import { VIEW_LABELS } from './describe.js';
5
+ import { contrastRatio, isHexColor, presetTheme, resolveThemeColors, THEME_PRESETS, THEME_TOKENS, TOKEN_LABELS } from './themes.js';
6
+ const LABELS = {
7
+ title: 'Colors',
8
+ preset: 'Preset',
9
+ scope: 'Apply changes to',
10
+ allViews: 'All views',
11
+ thisView: 'This view only',
12
+ reset: 'Reset to preset',
13
+ lowContrast: 'Low contrast with the card color',
14
+ invalid: 'Use a hex color like #1d2330',
15
+ };
16
+ const full = (hex) => (hex.length === 4 ? hex.replace(/^#(.)(.)(.)$/, '#$1$1$2$2$3$3') : hex).toLowerCase();
17
+ /** Pick a preset and adjust any color with a color picker or hex code, for all views or the current one. */
18
+ export function ThemeEditor({ value, view, onChange, presets = THEME_PRESETS, labels: l }) {
19
+ const labels = { ...LABELS, ...l };
20
+ const id = useId();
21
+ const [scope, setScope] = useState('all');
22
+ const [drafts, setDrafts] = useState({});
23
+ const colors = resolveThemeColors(value, view);
24
+ const base = presets.find((p) => p.id === value.id) ?? presetTheme(value.id);
25
+ const set = (token, hex) => {
26
+ setDrafts((d) => ({ ...d, [token]: hex }));
27
+ if (!isHexColor(hex))
28
+ return;
29
+ const color = full(hex);
30
+ onChange(scope === 'all'
31
+ ? { ...value, colors: { ...value.colors, [token]: color } }
32
+ : { ...value, views: { ...value.views, [view]: { ...value.views?.[view], [token]: color } } });
33
+ setDrafts((d) => ({ ...d, [token]: undefined }));
34
+ };
35
+ return (_jsxs("div", { className: "op-theme-editor", role: "group", "aria-labelledby": `${id}-title`, children: [_jsx("p", { id: `${id}-title`, className: "op-theme-editor-title", children: labels.title }), _jsxs("label", { className: "op-theme-row", children: [_jsx("span", { children: labels.preset }), _jsx("select", { value: value.id, onChange: (e) => {
36
+ const p = presets.find((x) => x.id === e.target.value);
37
+ if (p)
38
+ onChange(structuredClone(p));
39
+ setDrafts({});
40
+ }, children: presets.map((p) => (_jsx("option", { value: p.id, children: p.label }, p.id))) })] }), _jsxs("fieldset", { className: "op-theme-scope", children: [_jsx("legend", { children: labels.scope }), _jsxs("label", { children: [_jsx("input", { type: "radio", name: `${id}-scope`, checked: scope === 'all', onChange: () => setScope('all') }), " ", labels.allViews] }), _jsxs("label", { children: [_jsx("input", { type: "radio", name: `${id}-scope`, checked: scope === 'view', onChange: () => setScope('view') }), " ", labels.thisView, " (", VIEW_LABELS[view], ")"] })] }), THEME_TOKENS.map((token) => {
41
+ const shown = drafts[token] ?? colors[token];
42
+ const invalid = drafts[token] !== undefined && !isHexColor(drafts[token]);
43
+ const low = (token === 'label' || token === 'sublabel' || token === 'nodeBackground') &&
44
+ contrastRatio(token === 'nodeBackground' ? colors.label : colors[token], colors.nodeBackground) < (token === 'sublabel' ? 3 : 4.5);
45
+ return (_jsxs("div", { className: "op-theme-row", "data-token": token, children: [_jsx("label", { htmlFor: `${id}-${token}`, children: TOKEN_LABELS[token] }), _jsx("input", { type: "color", "aria-label": `${TOKEN_LABELS[token]} picker`, value: full(colors[token]), onChange: (e) => set(token, e.target.value) }), _jsx("input", { id: `${id}-${token}`, type: "text", inputMode: "text", spellCheck: false, maxLength: 7, value: shown, "aria-invalid": invalid || undefined, "aria-describedby": invalid || low ? `${id}-${token}-hint` : undefined, onChange: (e) => set(token, e.target.value.trim()) }), (invalid || low) && (_jsx("span", { id: `${id}-${token}-hint`, className: "op-theme-hint", children: invalid ? labels.invalid : labels.lowContrast }))] }, token));
46
+ }), base && (_jsx("button", { type: "button", onClick: () => {
47
+ onChange(structuredClone(base));
48
+ setDrafts({});
49
+ }, children: labels.reset }))] }));
50
+ }
@@ -0,0 +1,27 @@
1
+ import type { Individual, PedigreeViewId, PedigreeViewProjection, ReproductiveStructure } from '@captiveorigins/onepedigree-core';
2
+ export declare const VIEW_LABELS: Record<PedigreeViewId, string>;
3
+ export type Terminology = {
4
+ individual: string;
5
+ individuals: string;
6
+ parent: string;
7
+ cohort: string;
8
+ repeated: string;
9
+ unknown: string;
10
+ privateRecord: string;
11
+ };
12
+ export declare const DEFAULT_TERMS: Terminology;
13
+ /** Display name: the label, else the ID; anonymous placeholders (`data.private`) never show their ID. */
14
+ export declare const labelOf: (individual: Individual | undefined, terms?: Terminology) => string;
15
+ /** Words for an individual's origin, so color or shape is never the only signal. */
16
+ export declare function describeStructure(s: ReproductiveStructure | undefined): string;
17
+ /** A text summary of the current projection (screen readers, `<desc>`, print). */
18
+ export declare function describeProjection(p: PedigreeViewProjection, terms?: Terminology): string;
19
+ /** SVG path of a ring segment (fan chart); angles in radians, 0 = up, clockwise. */
20
+ export declare function arcPath(a: {
21
+ cx: number;
22
+ cy: number;
23
+ innerRadius: number;
24
+ outerRadius: number;
25
+ startAngle: number;
26
+ endAngle: number;
27
+ }): string;
@@ -0,0 +1,75 @@
1
+ export const VIEW_LABELS = {
2
+ pedigree: 'Pedigree',
3
+ fan: 'Fan chart',
4
+ ahnentafel: 'Ahnentafel',
5
+ descendants: 'Descendants',
6
+ hourglass: 'Hourglass',
7
+ cohort: 'Cohort',
8
+ relationship: 'Relationship',
9
+ timeline: 'Timeline',
10
+ inbreeding: 'Inbreeding',
11
+ population: 'Population',
12
+ compact: 'Compact pedigree',
13
+ maternal: 'Maternal line',
14
+ paternal: 'Paternal line',
15
+ };
16
+ export const DEFAULT_TERMS = {
17
+ individual: 'individual',
18
+ individuals: 'individuals',
19
+ parent: 'parent',
20
+ cohort: 'cohort',
21
+ repeated: 'appears more than once',
22
+ unknown: 'Unknown',
23
+ privateRecord: 'Private record',
24
+ };
25
+ /** Display name: the label, else the ID; anonymous placeholders (`data.private`) never show their ID. */
26
+ export const labelOf = (individual, terms = DEFAULT_TERMS) => individual?.data?.private ? terms.privateRecord : individual?.label || individual?.id || terms.unknown;
27
+ const MODE_TEXT = {
28
+ sexual: 'sexual reproduction',
29
+ parthenogenesis: 'parthenogenesis',
30
+ self: 'self-fertilization',
31
+ asexual: 'asexual origin',
32
+ cloning: 'cloning',
33
+ other: 'other origin',
34
+ };
35
+ /** Words for an individual's origin, so color or shape is never the only signal. */
36
+ export function describeStructure(s) {
37
+ if (!s)
38
+ return 'parentage not recorded';
39
+ const mode = s.reproductiveMode ? MODE_TEXT[s.reproductiveMode] : 'mode unknown';
40
+ const parents = `${s.recordedParentCount} recorded parent${s.recordedParentCount === 1 ? '' : 's'}`;
41
+ return [mode, parents, s.completeness === 'incomplete' ? 'incomplete' : null, s.atypical ? 'atypical' : null].filter(Boolean).join(', ');
42
+ }
43
+ /** A text summary of the current projection (screen readers, `<desc>`, print). */
44
+ export function describeProjection(p, terms = DEFAULT_TERMS) {
45
+ const focal = labelOf(p.individuals[p.focalId], terms);
46
+ const identities = new Set(p.occurrences.map((o) => o.individualId)).size;
47
+ const gens = p.occurrences.map((o) => o.generation);
48
+ const span = gens.length ? Math.max(...gens) - Math.min(...gens) : 0;
49
+ const repeated = new Set(p.occurrences.filter((o) => o.repeated).map((o) => o.individualId)).size;
50
+ return [
51
+ `${VIEW_LABELS[p.view]} of ${focal}: ${identities} ${identities === 1 ? terms.individual : terms.individuals} across ${span + 1} generation${span ? 's' : ''}.`,
52
+ repeated ? `${repeated} ${repeated === 1 ? terms.individual : terms.individuals} ${terms.repeated}.` : null,
53
+ p.truncated ? 'The view is limited; more records exist.' : null,
54
+ ...p.issues.map((i) => i.message),
55
+ ]
56
+ .filter(Boolean)
57
+ .join(' ');
58
+ }
59
+ /** SVG path of a ring segment (fan chart); angles in radians, 0 = up, clockwise. */
60
+ export function arcPath(a) {
61
+ const pt = (r, t) => `${(a.cx + r * Math.sin(t)).toFixed(2)} ${(a.cy - r * Math.cos(t)).toFixed(2)}`;
62
+ const large = a.endAngle - a.startAngle > Math.PI ? 1 : 0;
63
+ // The focal individual: a full disc at the center.
64
+ if (a.innerRadius <= 0) {
65
+ const r = a.outerRadius;
66
+ return `M ${a.cx - r} ${a.cy} A ${r} ${r} 0 1 0 ${a.cx + r} ${a.cy} A ${r} ${r} 0 1 0 ${a.cx - r} ${a.cy} Z`;
67
+ }
68
+ return [
69
+ `M ${pt(a.outerRadius, a.startAngle)}`,
70
+ `A ${a.outerRadius} ${a.outerRadius} 0 ${large} 1 ${pt(a.outerRadius, a.endAngle)}`,
71
+ `L ${pt(a.innerRadius, a.endAngle)}`,
72
+ `A ${a.innerRadius} ${a.innerRadius} 0 ${large} 0 ${pt(a.innerRadius, a.startAngle)}`,
73
+ 'Z',
74
+ ].join(' ');
75
+ }
@@ -0,0 +1,3 @@
1
+ export { usePedigreeLayout, usePedigreeNavigation, usePedigreeProjection, usePedigreeSelection, useOccurrenceHighlighting, nearestNode, type Direction } from './hooks.js';
2
+ export { arcPath, DEFAULT_TERMS, describeProjection, describeStructure, labelOf, VIEW_LABELS, type Terminology } from './describe.js';
3
+ export { contrastRatio, isHexColor, presetTheme, resolveThemeColors, THEME_PRESETS, THEME_TOKENS, themeStyle, TOKEN_LABELS, type PedigreeTheme, type ThemeColors, type ThemeToken, } from './themes.js';
@@ -0,0 +1,3 @@
1
+ export { usePedigreeLayout, usePedigreeNavigation, usePedigreeProjection, usePedigreeSelection, useOccurrenceHighlighting, nearestNode } from './hooks.js';
2
+ export { arcPath, DEFAULT_TERMS, describeProjection, describeStructure, labelOf, VIEW_LABELS } from './describe.js';
3
+ export { contrastRatio, isHexColor, presetTheme, resolveThemeColors, THEME_PRESETS, THEME_TOKENS, themeStyle, TOKEN_LABELS, } from './themes.js';
@@ -0,0 +1,21 @@
1
+ import { type LayoutOptions, type LayoutResult, type PedigreeDocument, type PedigreeIndex, type PedigreeLayoutEngine, type PedigreeViewProjection, type ProjectionOptions } from '@captiveorigins/onepedigree-core';
2
+ /** Memoized projection of a document (or index) for one view. */
3
+ export declare function usePedigreeProjection(source: PedigreeDocument | PedigreeIndex, options: ProjectionOptions): PedigreeViewProjection;
4
+ /**
5
+ * Geometry for a projection. The built-in engine runs synchronously (same
6
+ * result on server and client, so hydration matches); a custom engine (e.g.
7
+ * ELK) resolves asynchronously and returns null until ready.
8
+ */
9
+ export declare function usePedigreeLayout(projection: PedigreeViewProjection, options?: LayoutOptions, engine?: PedigreeLayoutEngine): LayoutResult | null;
10
+ /** Selected individual (an identity, not an occurrence). */
11
+ export declare function usePedigreeSelection(initial?: string | null): {
12
+ selectedId: string | null;
13
+ select: (id: string | null) => void;
14
+ clear: () => void;
15
+ };
16
+ /** Every visible occurrence of the selected individual (repeated ancestors light up together). */
17
+ export declare function useOccurrenceHighlighting(projection: PedigreeViewProjection, individualId: string | null): ReadonlySet<string>;
18
+ export type Direction = 'up' | 'down' | 'left' | 'right';
19
+ /** Spatial keyboard navigation: the nearest node in a direction from the current one. */
20
+ export declare function nearestNode(layout: LayoutResult, from: string, direction: Direction): string | null;
21
+ export declare function usePedigreeNavigation(layout: LayoutResult | null): (from: string, direction: Direction) => string | null;
package/dist/hooks.js ADDED
@@ -0,0 +1,68 @@
1
+ import { layoutSync, projectPedigree, } from '@captiveorigins/onepedigree-core';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ /** Memoized projection of a document (or index) for one view. */
4
+ export function usePedigreeProjection(source, options) {
5
+ const key = JSON.stringify(options);
6
+ // eslint-disable-next-line react-hooks/exhaustive-deps
7
+ return useMemo(() => projectPedigree(source, options), [source, key]);
8
+ }
9
+ /**
10
+ * Geometry for a projection. The built-in engine runs synchronously (same
11
+ * result on server and client, so hydration matches); a custom engine (e.g.
12
+ * ELK) resolves asynchronously and returns null until ready.
13
+ */
14
+ export function usePedigreeLayout(projection, options = {}, engine) {
15
+ const key = JSON.stringify(options);
16
+ // eslint-disable-next-line react-hooks/exhaustive-deps
17
+ const sync = useMemo(() => (engine ? null : layoutSync(projection, options)), [projection, key, engine]);
18
+ const [custom, setCustom] = useState(null);
19
+ useEffect(() => {
20
+ if (!engine)
21
+ return;
22
+ let live = true;
23
+ engine.layout(projection, options).then((r) => live && setCustom(r));
24
+ return () => {
25
+ live = false;
26
+ };
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, [projection, key, engine]);
29
+ return sync ?? custom;
30
+ }
31
+ /** Selected individual (an identity, not an occurrence). */
32
+ export function usePedigreeSelection(initial = null) {
33
+ const [selectedId, setSelectedId] = useState(initial);
34
+ const select = useCallback((id) => setSelectedId(id), []);
35
+ const clear = useCallback(() => setSelectedId(null), []);
36
+ return { selectedId, select, clear };
37
+ }
38
+ /** Every visible occurrence of the selected individual (repeated ancestors light up together). */
39
+ export function useOccurrenceHighlighting(projection, individualId) {
40
+ return useMemo(() => new Set(individualId ? projection.occurrences.filter((o) => o.individualId === individualId).map((o) => o.occurrenceId) : []), [projection, individualId]);
41
+ }
42
+ /** Spatial keyboard navigation: the nearest node in a direction from the current one. */
43
+ export function nearestNode(layout, from, direction) {
44
+ const center = (n) => ({ x: n.x + n.width / 2, y: n.y + n.height / 2 });
45
+ const start = layout.nodes.find((n) => n.occurrenceId === from);
46
+ if (!start)
47
+ return layout.nodes[0]?.occurrenceId ?? null;
48
+ const a = center(start);
49
+ let best = null;
50
+ for (const n of layout.nodes) {
51
+ if (n === start)
52
+ continue;
53
+ const b = center(n);
54
+ const dx = b.x - a.x;
55
+ const dy = b.y - a.y;
56
+ const along = direction === 'up' ? -dy : direction === 'down' ? dy : direction === 'left' ? -dx : dx;
57
+ const across = direction === 'up' || direction === 'down' ? Math.abs(dx) : Math.abs(dy);
58
+ if (along <= 1)
59
+ continue;
60
+ const score = along + across * 2;
61
+ if (!best || score < best.score)
62
+ best = { id: n.occurrenceId, score };
63
+ }
64
+ return best?.id ?? null;
65
+ }
66
+ export function usePedigreeNavigation(layout) {
67
+ return useCallback((from, direction) => (layout ? nearestNode(layout, from, direction) : null), [layout]);
68
+ }
@@ -0,0 +1,4 @@
1
+ export { PedigreeView, type CohortMarkerSlotProps, type IndividualSlotProps, type PedigreeClassNames, type PedigreeViewProps } from './PedigreeView.js';
2
+ export { PedigreeExplorer, type PedigreeExplorerProps, type QuickViewProps } from './PedigreeExplorer.js';
3
+ export * from './headless.js';
4
+ export { ThemeEditor, type ThemeEditorProps } from './ThemeEditor.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { PedigreeView } from './PedigreeView.js';
2
+ export { PedigreeExplorer } from './PedigreeExplorer.js';
3
+ export * from './headless.js';
4
+ export { ThemeEditor } from './ThemeEditor.js';
@@ -0,0 +1,117 @@
1
+ /*
2
+ * OnePedigree default styles. Optional: skip this file for unstyled mode and
3
+ * style the stable .op-* classes and data-* attributes yourself, or override
4
+ * the custom properties below.
5
+ */
6
+ .op-root {
7
+ --op-font: system-ui, -apple-system, 'Segoe UI', sans-serif;
8
+ --op-node-background: #ffffff;
9
+ --op-node-border: #6b7a90;
10
+ --op-node-border-width: 1.5px;
11
+ --op-node-border-style: solid;
12
+ --op-label: #1d2330;
13
+ --op-sublabel: #5b6577;
14
+ --op-connector: #8892a6;
15
+ --op-connector-width: 1.5px;
16
+ --op-focal-border: #2f7d5c;
17
+ --op-selected-ring: #f0b35a;
18
+ --op-highlight: #d97706;
19
+ --op-cohort-border: #9aa5b8;
20
+ --op-cohort-label: #5b6577;
21
+ --op-junction: #5b6577;
22
+ --op-fan-fill: #eef2f7;
23
+ --op-fan-fill-alt: #e3e9f1;
24
+ font-family: var(--op-font);
25
+ overflow: visible;
26
+ }
27
+ @media (prefers-color-scheme: dark) {
28
+ .op-root {
29
+ --op-node-background: #161a22;
30
+ --op-node-border: #7dc4a5;
31
+ --op-label: #eef1f6;
32
+ --op-sublabel: #a7b0c0;
33
+ --op-connector: #8892a6;
34
+ --op-cohort-border: #4b5567;
35
+ --op-cohort-label: #a7b0c0;
36
+ --op-junction: #c3cad6;
37
+ --op-fan-fill: #1d2330;
38
+ --op-fan-fill-alt: #232a37;
39
+ }
40
+ }
41
+ .op-node-box {
42
+ fill: var(--op-node-background);
43
+ stroke: var(--op-node-border);
44
+ stroke-width: var(--op-node-border-width);
45
+ stroke-dasharray: var(--op-node-border-dash, none);
46
+ }
47
+ .op-label { fill: var(--op-label); font-size: 13px; font-weight: 600; }
48
+ .op-sublabel { fill: var(--op-sublabel); font-size: 11px; }
49
+ .op-repeat-marker { fill: var(--op-highlight); font-size: 13px; }
50
+ .op-connector { fill: none; stroke: var(--op-connector); stroke-width: var(--op-connector-width); }
51
+ /* Reproductive mode by line pattern (never color alone). */
52
+ .op-connector[data-reproductive-mode='parthenogenesis'] { stroke-dasharray: 6 4; }
53
+ .op-connector[data-reproductive-mode='self'] { stroke-dasharray: 2 3; }
54
+ .op-connector[data-reproductive-mode='asexual'],
55
+ .op-connector[data-reproductive-mode='cloning'] { stroke-dasharray: 1 4; stroke-linecap: round; }
56
+ .op-connector[data-completeness='incomplete'] { stroke-opacity: 0.6; }
57
+ .op-connector[data-highlighted] { stroke: var(--op-highlight); stroke-width: 2.5px; }
58
+ .op-junction { fill: var(--op-junction); stroke: var(--op-junction); stroke-width: 1.5px; }
59
+ .op-junction[data-completeness='incomplete'] { fill: var(--op-node-background); }
60
+ .op-individual[data-repeated] { --op-node-border-dash: 5 3; }
61
+ .op-individual[data-focal] .op-node-box { stroke: var(--op-focal-border); stroke-width: 2.5px; }
62
+ .op-individual[data-highlighted] .op-node-box,
63
+ .op-individual[data-highlighted] .op-fan-segment { stroke: var(--op-highlight); stroke-width: 2.5px; }
64
+ .op-individual[data-selected] .op-node-box,
65
+ .op-individual[data-selected] .op-fan-segment { stroke: var(--op-selected-ring); stroke-width: 3.5px; }
66
+ .op-individual[data-collapsed] { opacity: 0.75; }
67
+ .op-individual[role='button'] { cursor: pointer; }
68
+ .op-individual:focus { outline: none; }
69
+ .op-individual:focus-visible .op-node-box,
70
+ .op-individual:focus-visible .op-fan-segment { stroke: var(--op-selected-ring); stroke-width: 3px; stroke-dasharray: 3 2; }
71
+ .op-individual a:focus-visible .op-node-box { stroke: var(--op-selected-ring); stroke-width: 3px; }
72
+ .op-fan-segment { fill: var(--op-fan-fill); stroke: var(--op-node-border); stroke-width: 1px; }
73
+ .op-individual[data-generation='1'] .op-fan-segment,
74
+ .op-individual[data-generation='3'] .op-fan-segment,
75
+ .op-individual[data-generation='5'] .op-fan-segment { fill: var(--op-fan-fill-alt); }
76
+ .op-cohort-box { fill: none; stroke: var(--op-cohort-border); stroke-dasharray: 4 4; }
77
+ .op-cohort-label { fill: var(--op-cohort-label); font-size: 11px; }
78
+
79
+ .op-explorer { position: relative; overflow: hidden; touch-action: none; border-radius: 12px; }
80
+ .op-explorer > svg { display: block; width: 100%; height: 100%; cursor: grab; }
81
+ .op-explorer > svg:active { cursor: grabbing; }
82
+ .op-toolbar { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; z-index: 1; }
83
+ .op-toolbar button {
84
+ min-width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--op-node-border, #6b7a90);
85
+ background: var(--op-node-background, #fff); color: inherit; font: inherit; cursor: pointer;
86
+ }
87
+ .op-toolbar button:focus-visible { outline: 2px solid #f0b35a; outline-offset: 2px; }
88
+ .op-quick-view {
89
+ position: absolute; left: 8px; bottom: 8px; max-width: 18rem; padding: 12px 14px; border-radius: 10px;
90
+ background: var(--op-node-background, #fff); border: 1px solid var(--op-node-border, #6b7a90);
91
+ font: 13px/1.4 system-ui, sans-serif; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
92
+ }
93
+ .op-quick-view p { margin: 0 0 4px; }
94
+ .op-quick-view-title { font-weight: 600; }
95
+ .op-quick-view-actions { display: flex; gap: 8px; margin-top: 8px; }
96
+ .op-theme-panel {
97
+ position: absolute; top: 48px; right: 8px; z-index: 2; max-height: calc(100% - 56px); overflow: auto; width: 20rem;
98
+ padding: 12px; border-radius: 10px; background: var(--op-node-background, #fff); color: var(--op-label, #1d2330);
99
+ border: 1px solid var(--op-node-border, #6b7a90); font: 13px/1.4 system-ui, sans-serif; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
100
+ }
101
+ .op-theme-editor-title { font-weight: 600; margin: 0 0 8px; }
102
+ .op-theme-row { display: grid; grid-template-columns: 1fr 2.25rem 5.5rem; align-items: center; gap: 6px; margin: 4px 0; }
103
+ .op-theme-row select { grid-column: 2 / 4; }
104
+ .op-theme-row input[type='color'] { width: 2.25rem; height: 1.75rem; padding: 0; border: 1px solid #aab; border-radius: 6px; background: none; }
105
+ .op-theme-row input[type='text'] { font: 12px ui-monospace, monospace; padding: 3px 6px; border: 1px solid #aab; border-radius: 6px; }
106
+ .op-theme-row input[aria-invalid='true'] { border-color: #c00; }
107
+ .op-theme-hint { grid-column: 1 / 4; font-size: 11px; color: #b45309; }
108
+ .op-theme-scope { border: 0; padding: 0; margin: 6px 0; display: flex; flex-wrap: wrap; gap: 4px 12px; }
109
+ .op-theme-scope legend { width: 100%; margin-bottom: 2px; }
110
+ @media (prefers-reduced-motion: reduce) {
111
+ .op-root * { transition: none !important; animation: none !important; }
112
+ }
113
+ @media print {
114
+ .op-toolbar, .op-quick-view, .op-theme-panel { display: none; }
115
+ .op-explorer { height: auto !important; overflow: visible; }
116
+ .op-root { --op-node-background: #fff; --op-label: #000; --op-connector: #555; }
117
+ }
@@ -0,0 +1,24 @@
1
+ import type { PedigreeViewId } from '@captiveorigins/onepedigree-core';
2
+ import type { CSSProperties } from 'react';
3
+ /** Colors a theme sets; each maps to a `--op-*` custom property. */
4
+ export declare const THEME_TOKENS: readonly ["background", "nodeBackground", "nodeBorder", "label", "sublabel", "connector", "focalBorder", "selectedRing", "highlight", "cohortBorder", "junction", "fanFill", "fanFillAlt"];
5
+ export type ThemeToken = (typeof THEME_TOKENS)[number];
6
+ export type ThemeColors = Record<ThemeToken, string>;
7
+ export declare const TOKEN_LABELS: Record<ThemeToken, string>;
8
+ export type PedigreeTheme = {
9
+ id: string;
10
+ label: string;
11
+ colors: ThemeColors;
12
+ /** Per-view adjustments on top of `colors` (e.g. warm maternal, cool paternal lines). */
13
+ views?: Partial<Record<PedigreeViewId, Partial<ThemeColors>>>;
14
+ };
15
+ export declare const isHexColor: (v: unknown) => v is string;
16
+ /** Built-in presets, each with its own per-view accents. */
17
+ export declare const THEME_PRESETS: readonly PedigreeTheme[];
18
+ export declare const presetTheme: (id: string) => PedigreeTheme | undefined;
19
+ /** Colors for one view: preset, then its view accents, then the theme's own overrides; invalid hex values are ignored. */
20
+ export declare function resolveThemeColors(themeOrId: PedigreeTheme | string | undefined, view: PedigreeViewId): ThemeColors;
21
+ /** Inline custom properties for a view (works in server rendering). No theme: none (the stylesheet decides). */
22
+ export declare function themeStyle(themeOrId: PedigreeTheme | string | undefined, view: PedigreeViewId): CSSProperties | undefined;
23
+ /** WCAG contrast ratio of two hex colors (1–21). */
24
+ export declare function contrastRatio(a: string, b: string): number;
package/dist/themes.js ADDED
@@ -0,0 +1,216 @@
1
+ /** Colors a theme sets; each maps to a `--op-*` custom property. */
2
+ export const THEME_TOKENS = [
3
+ 'background',
4
+ 'nodeBackground',
5
+ 'nodeBorder',
6
+ 'label',
7
+ 'sublabel',
8
+ 'connector',
9
+ 'focalBorder',
10
+ 'selectedRing',
11
+ 'highlight',
12
+ 'cohortBorder',
13
+ 'junction',
14
+ 'fanFill',
15
+ 'fanFillAlt',
16
+ ];
17
+ export const TOKEN_LABELS = {
18
+ background: 'Background',
19
+ nodeBackground: 'Card',
20
+ nodeBorder: 'Card border',
21
+ label: 'Name text',
22
+ sublabel: 'Detail text',
23
+ connector: 'Lines',
24
+ focalBorder: 'Focal individual',
25
+ selectedRing: 'Selection',
26
+ highlight: 'Highlight',
27
+ cohortBorder: 'Cohort outline',
28
+ junction: 'Junctions',
29
+ fanFill: 'Fan rings',
30
+ fanFillAlt: 'Fan rings (alternate)',
31
+ };
32
+ const VAR = {
33
+ background: '--op-background',
34
+ nodeBackground: '--op-node-background',
35
+ nodeBorder: '--op-node-border',
36
+ label: '--op-label',
37
+ sublabel: '--op-sublabel',
38
+ connector: '--op-connector',
39
+ focalBorder: '--op-focal-border',
40
+ selectedRing: '--op-selected-ring',
41
+ highlight: '--op-highlight',
42
+ cohortBorder: '--op-cohort-border',
43
+ junction: '--op-junction',
44
+ fanFill: '--op-fan-fill',
45
+ fanFillAlt: '--op-fan-fill-alt',
46
+ };
47
+ export const isHexColor = (v) => typeof v === 'string' && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(v);
48
+ const theme = (id, label, colors, views) => ({ id, label, colors, views });
49
+ /** Built-in presets, each with its own per-view accents. */
50
+ export const THEME_PRESETS = [
51
+ theme('classic', 'Classic', {
52
+ background: '#ffffff',
53
+ nodeBackground: '#ffffff',
54
+ nodeBorder: '#6b7a90',
55
+ label: '#1d2330',
56
+ sublabel: '#5b6577',
57
+ connector: '#8892a6',
58
+ focalBorder: '#2f7d5c',
59
+ selectedRing: '#f0b35a',
60
+ highlight: '#d97706',
61
+ cohortBorder: '#9aa5b8',
62
+ junction: '#5b6577',
63
+ fanFill: '#eef2f7',
64
+ fanFillAlt: '#e3e9f1',
65
+ }, {
66
+ maternal: { focalBorder: '#b4436c', connector: '#c76b8e', nodeBorder: '#c76b8e' },
67
+ paternal: { focalBorder: '#2b6cb0', connector: '#5a8fd0', nodeBorder: '#5a8fd0' },
68
+ inbreeding: { highlight: '#c2410c' },
69
+ relationship: { highlight: '#7c3aed' },
70
+ fan: { fanFill: '#e8f1ec', fanFillAlt: '#d7e8de' },
71
+ timeline: { connector: '#a7b0c0' },
72
+ population: { connector: '#b4bccb' },
73
+ }),
74
+ theme('midnight', 'Midnight', {
75
+ background: '#0e1117',
76
+ nodeBackground: '#161a22',
77
+ nodeBorder: '#7dc4a5',
78
+ label: '#eef1f6',
79
+ sublabel: '#a7b0c0',
80
+ connector: '#8892a6',
81
+ focalBorder: '#7dc4a5',
82
+ selectedRing: '#f0b35a',
83
+ highlight: '#f59e0b',
84
+ cohortBorder: '#4b5567',
85
+ junction: '#c3cad6',
86
+ fanFill: '#1d2330',
87
+ fanFillAlt: '#232a37',
88
+ }, {
89
+ maternal: { nodeBorder: '#f29bb9', connector: '#f29bb9', focalBorder: '#f29bb9' },
90
+ paternal: { nodeBorder: '#8ab8f0', connector: '#8ab8f0', focalBorder: '#8ab8f0' },
91
+ inbreeding: { highlight: '#fb923c' },
92
+ relationship: { highlight: '#c4b5fd' },
93
+ fan: { fanFill: '#18232a', fanFillAlt: '#1f2e35' },
94
+ }),
95
+ theme('forest', 'Forest', {
96
+ background: '#f6f8f3',
97
+ nodeBackground: '#ffffff',
98
+ nodeBorder: '#5d7a52',
99
+ label: '#1f2a1c',
100
+ sublabel: '#56654f',
101
+ connector: '#8aa07e',
102
+ focalBorder: '#2f5d2a',
103
+ selectedRing: '#d9a441',
104
+ highlight: '#b45309',
105
+ cohortBorder: '#a3b398',
106
+ junction: '#56654f',
107
+ fanFill: '#e6efdf',
108
+ fanFillAlt: '#d6e4cb',
109
+ }, {
110
+ maternal: { nodeBorder: '#8f5e3b', connector: '#a8774f' },
111
+ paternal: { nodeBorder: '#3d6b62', connector: '#5b8a80' },
112
+ relationship: { highlight: '#9a3412' },
113
+ }),
114
+ theme('ocean', 'Ocean', {
115
+ background: '#f3f8fb',
116
+ nodeBackground: '#ffffff',
117
+ nodeBorder: '#4c7a99',
118
+ label: '#122533',
119
+ sublabel: '#4b6475',
120
+ connector: '#7fa3bb',
121
+ focalBorder: '#0f5e8c',
122
+ selectedRing: '#f2a65a',
123
+ highlight: '#e07a2e',
124
+ cohortBorder: '#9fbccf',
125
+ junction: '#4b6475',
126
+ fanFill: '#e2eef6',
127
+ fanFillAlt: '#d0e3ef',
128
+ }, {
129
+ maternal: { nodeBorder: '#a35a8a', connector: '#bb7aa4' },
130
+ paternal: { nodeBorder: '#1f6fa3', connector: '#4f92c0' },
131
+ inbreeding: { highlight: '#c2410c' },
132
+ }),
133
+ theme('terracotta', 'Terracotta', {
134
+ background: '#fbf6f1',
135
+ nodeBackground: '#fffdfa',
136
+ nodeBorder: '#a0674a',
137
+ label: '#2d1d14',
138
+ sublabel: '#6e5446',
139
+ connector: '#c49a83',
140
+ focalBorder: '#8a3f1f',
141
+ selectedRing: '#3f7f6f',
142
+ highlight: '#3f7f6f',
143
+ cohortBorder: '#d9bba8',
144
+ junction: '#6e5446',
145
+ fanFill: '#f5e6da',
146
+ fanFillAlt: '#ecd5c4',
147
+ }, {
148
+ maternal: { nodeBorder: '#b0546b', connector: '#c57789' },
149
+ paternal: { nodeBorder: '#4f6f8a', connector: '#7290a8' },
150
+ }),
151
+ theme('high-contrast', 'High contrast', {
152
+ background: '#ffffff',
153
+ nodeBackground: '#ffffff',
154
+ nodeBorder: '#000000',
155
+ label: '#000000',
156
+ sublabel: '#1a1a1a',
157
+ connector: '#000000',
158
+ focalBorder: '#0000cc',
159
+ selectedRing: '#cc0000',
160
+ highlight: '#cc0000',
161
+ cohortBorder: '#000000',
162
+ junction: '#000000',
163
+ fanFill: '#ffffff',
164
+ fanFillAlt: '#e6e6e6',
165
+ }, {}),
166
+ theme('print', 'Print (grayscale)', {
167
+ background: '#ffffff',
168
+ nodeBackground: '#ffffff',
169
+ nodeBorder: '#444444',
170
+ label: '#000000',
171
+ sublabel: '#333333',
172
+ connector: '#555555',
173
+ focalBorder: '#000000',
174
+ selectedRing: '#000000',
175
+ highlight: '#000000',
176
+ cohortBorder: '#888888',
177
+ junction: '#333333',
178
+ fanFill: '#f2f2f2',
179
+ fanFillAlt: '#e0e0e0',
180
+ }, {}),
181
+ ];
182
+ export const presetTheme = (id) => THEME_PRESETS.find((t) => t.id === id);
183
+ /** Colors for one view: preset, then its view accents, then the theme's own overrides; invalid hex values are ignored. */
184
+ export function resolveThemeColors(themeOrId, view) {
185
+ const t = typeof themeOrId === 'string' ? presetTheme(themeOrId) : themeOrId;
186
+ // Invalid or missing values fall back to the theme's own preset (else Classic).
187
+ const preset = (t && presetTheme(t.id)) ?? THEME_PRESETS[0];
188
+ const out = { ...preset.colors };
189
+ for (const layer of [preset !== t ? preset.views?.[view] : undefined, t?.colors, t?.views?.[view]])
190
+ for (const k of THEME_TOKENS) {
191
+ const v = layer?.[k];
192
+ if (isHexColor(v))
193
+ out[k] = v;
194
+ }
195
+ return out;
196
+ }
197
+ /** Inline custom properties for a view (works in server rendering). No theme: none (the stylesheet decides). */
198
+ export function themeStyle(themeOrId, view) {
199
+ if (!themeOrId)
200
+ return undefined;
201
+ const colors = resolveThemeColors(themeOrId, view);
202
+ return Object.fromEntries(THEME_TOKENS.map((k) => [VAR[k], colors[k]]));
203
+ }
204
+ /** WCAG contrast ratio of two hex colors (1–21). */
205
+ export function contrastRatio(a, b) {
206
+ const lum = (hex) => {
207
+ const h = hex.length === 4 ? hex.replace(/^#(.)(.)(.)$/, '#$1$1$2$2$3$3') : hex;
208
+ const [r, g, bl] = [1, 3, 5].map((i) => {
209
+ const c = parseInt(h.slice(i, i + 2), 16) / 255;
210
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
211
+ });
212
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
213
+ };
214
+ const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m);
215
+ return (x + 0.05) / (y + 0.05);
216
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@captiveorigins/onepedigree-react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-alpha.5",
4
+ "description": "OnePedigree for React: server-renderable SVG pedigree views (all 13), interactive explorer, headless hooks and CSS-variable styling.",
5
+ "keywords": [
6
+ "pedigree",
7
+ "genealogy",
8
+ "family-tree",
9
+ "react",
10
+ "svg",
11
+ "breeding"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/mgilardi/onepedigree.git",
17
+ "directory": "packages/react"
18
+ },
19
+ "type": "module",
20
+ "sideEffects": [
21
+ "*.css"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ },
28
+ "./headless": {
29
+ "types": "./dist/headless.d.ts",
30
+ "import": "./dist/headless.js"
31
+ },
32
+ "./styles.css": "./dist/styles.css"
33
+ },
34
+ "main": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "dependencies": {
45
+ "@captiveorigins/onepedigree-core": "^0.1.0-alpha.5"
46
+ },
47
+ "peerDependencies": {
48
+ "react": ">=18.2"
49
+ },
50
+ "devDependencies": {
51
+ "@testing-library/react": "^16.3.3",
52
+ "@types/react": "^19.3.0",
53
+ "@types/react-dom": "^19.3.0",
54
+ "jsdom": "^30.1.3",
55
+ "react": "^19.3.1",
56
+ "react-dom": "^19.3.1"
57
+ },
58
+ "scripts": {
59
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/styles.css dist/styles.css",
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run"
62
+ }
6
63
  }