@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { estimateNodeWidth, lifeLabel, lifeSpan, nodeSublabel } from '@captiveorigins/onepedigree-core';
|
|
3
|
+
import { arcPath, DEFAULT_TERMS, describeProjection, describeStructure, labelOf, VIEW_LABELS } from './describe.js';
|
|
4
|
+
import { themeStyle } from './themes.js';
|
|
5
|
+
import { GenotypeGlyph, GenotypeStrip, genotypeText, genotypeWord } from './genetics.js';
|
|
6
|
+
const cx = (...c) => c.filter(Boolean).join(' ');
|
|
7
|
+
const short = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
8
|
+
/** Junction glyph: circle = two-parent mode, diamond = single-parent mode, square = variable/other; hollow = incomplete. */
|
|
9
|
+
function Junction({ s, x, y }) {
|
|
10
|
+
const shape = s.typicalStructure === 'pair' ? 'pair' : s.typicalStructure === 'single' ? 'single' : 'variable';
|
|
11
|
+
const common = {
|
|
12
|
+
className: 'op-junction',
|
|
13
|
+
'data-structure': shape,
|
|
14
|
+
'data-completeness': s.completeness,
|
|
15
|
+
'data-atypical': s.atypical || undefined,
|
|
16
|
+
};
|
|
17
|
+
const r = 5;
|
|
18
|
+
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 }));
|
|
19
|
+
return (_jsxs("g", { "aria-hidden": "true", children: [glyph, _jsx("title", { children: describeStructure(s) })] }));
|
|
20
|
+
}
|
|
21
|
+
function DefaultIndividual({ individual, occurrence, node, structure, terms, gene, genotypes }) {
|
|
22
|
+
const label = labelOf(individual, terms);
|
|
23
|
+
// Sex mark and life years ("♂ 1950 – 2010"), plus the Ahnentafel number where it applies.
|
|
24
|
+
const sub = [nodeSublabel(individual), occurrence.ahnentafel !== undefined ? `#${occurrence.ahnentafel}` : null].filter(Boolean).join(' · ');
|
|
25
|
+
// Nodes are sized to their names; names are only shortened when a host made the node narrower (or past the widest node).
|
|
26
|
+
const fits = node.width >= estimateNodeWidth(label, sub) - 1;
|
|
27
|
+
const chars = fits ? Infinity : Math.max(6, Math.floor((node.width - 20) / 7.4));
|
|
28
|
+
if (node.life)
|
|
29
|
+
return (_jsxs(_Fragment, { children: [_jsx("rect", { className: "op-node-box op-life-bar", width: node.width, height: node.height, rx: 6, ry: 6, "data-open": node.life.open || undefined }), _jsx("text", { className: "op-label", x: node.life.undated || node.width >= label.length * 7.4 + 16 ? 8 : node.width + 6, y: node.height / 2 + 4, children: label })] }));
|
|
30
|
+
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: genotypes ? (sub ? node.height / 2 - 9 : node.height / 2 - 3) : sub ? node.height / 2 - 3 : node.height / 2 + 4, children: short(label, chars) }), sub && (_jsx("text", { className: "op-sublabel", x: 10, y: genotypes ? node.height / 2 + 6 : 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 }), genotypes && (_jsx("g", { transform: `translate(10 ${node.height - 8})`, children: _jsx(GenotypeStrip, { display: genotypes, individualId: occurrence.individualId, r: 4 }) })), gene !== undefined && (_jsx("g", { transform: `translate(${node.width - 12} ${node.height - 12})`, children: _jsx(GenotypeGlyph, { mark: gene ?? undefined }) }))] }));
|
|
31
|
+
}
|
|
32
|
+
const RAD = 180 / Math.PI;
|
|
33
|
+
/**
|
|
34
|
+
* Fan labels follow the fan: along the arc in the wide inner rings (with life
|
|
35
|
+
* years when there is room), along the radius in the narrow outer ones; always
|
|
36
|
+
* upright, never upside down.
|
|
37
|
+
*/
|
|
38
|
+
function FanLabel({ arc, label, years, id }) {
|
|
39
|
+
const { cx: x0, cy: y0, innerRadius: r0, outerRadius: r1, startAngle: a0, endAngle: a1 } = arc;
|
|
40
|
+
if (r0 <= 0)
|
|
41
|
+
return (_jsxs("text", { className: "op-label op-fan-label", x: x0, y: y0 + (years ? -2 : 4), textAnchor: "middle", children: [short(label, Math.floor((2 * r1) / 7)), years && (_jsx("tspan", { className: "op-sublabel", x: x0, dy: 15, children: years }))] }));
|
|
42
|
+
const mid = (a0 + a1) / 2;
|
|
43
|
+
const r = (r0 + r1) / 2;
|
|
44
|
+
const ring = r1 - r0;
|
|
45
|
+
const arcLength = r * (a1 - a0);
|
|
46
|
+
const pt = (radius, t) => `${(x0 + radius * Math.sin(t)).toFixed(2)} ${(y0 - radius * Math.cos(t)).toFixed(2)}`;
|
|
47
|
+
if (arcLength >= ring * 1.2) {
|
|
48
|
+
// Along the arc; on the lower half the path runs the other way so the text stays upright.
|
|
49
|
+
const lower = Math.cos(mid) < 0;
|
|
50
|
+
const path = (radius) => lower ? `M ${pt(radius, a1)} A ${radius} ${radius} 0 0 0 ${pt(radius, a0)}` : `M ${pt(radius, a0)} A ${radius} ${radius} 0 0 1 ${pt(radius, a1)}`;
|
|
51
|
+
const yearsR = r + (Math.cos(mid) < 0 ? 9 : -9);
|
|
52
|
+
// Years only where they fit in the segment (never spilling into the next one).
|
|
53
|
+
const showYears = Boolean(years) && ring >= 46 && years.length * 6.4 + 8 <= yearsR * (a1 - a0);
|
|
54
|
+
const nameR = showYears ? r + (lower ? -7 : 7) : r + (lower ? -4 : 4);
|
|
55
|
+
return (_jsxs(_Fragment, { children: [_jsxs("defs", { children: [_jsx("path", { id: `${id}-n`, d: path(nameR) }), showYears && _jsx("path", { id: `${id}-y`, d: path(yearsR) })] }), _jsx("text", { className: "op-label op-fan-label", children: _jsx("textPath", { href: `#${id}-n`, startOffset: "50%", textAnchor: "middle", children: short(label, Math.floor((nameR * (a1 - a0)) / 7.2)) }) }), showYears && (_jsx("text", { className: "op-sublabel op-fan-label", children: _jsx("textPath", { href: `#${id}-y`, startOffset: "50%", textAnchor: "middle", children: years }) }))] }));
|
|
56
|
+
}
|
|
57
|
+
// Along the radius, reading outward on the right half and inward on the left.
|
|
58
|
+
const left = Math.sin(mid) < 0;
|
|
59
|
+
const [px, py] = pt(r, mid).split(' ').map(Number);
|
|
60
|
+
const rotate = mid * RAD - 90 + (left ? 180 : 0);
|
|
61
|
+
const size = Math.min(13, Math.max(8, arcLength * 0.6));
|
|
62
|
+
return (_jsx("text", { className: "op-label op-fan-label", x: px, y: py, textAnchor: "middle", dominantBaseline: "central", transform: `rotate(${rotate.toFixed(2)} ${px} ${py})`, style: { fontSize: size }, children: short(label, Math.floor((ring - 8) / (size * 0.56))) }));
|
|
63
|
+
}
|
|
64
|
+
/** The timeline's year axis: ticks every 1, 2, 5, 10, 25, 50 or 100 years, whichever keeps them readable. */
|
|
65
|
+
function TimelineAxis({ axis, width }) {
|
|
66
|
+
const step = [1, 2, 5, 10, 25, 50, 100, 250].find((s) => s * axis.pxPerYear >= 60) ?? 500;
|
|
67
|
+
const ticks = [];
|
|
68
|
+
for (let y = Math.ceil(axis.from / step) * step; y <= axis.to; y += step)
|
|
69
|
+
ticks.push(y);
|
|
70
|
+
return (_jsxs("g", { className: "op-timeline-axis", "aria-hidden": "true", children: [_jsx("line", { className: "op-axis-line", x1: 0, x2: width, y1: 22, y2: 22 }), ticks.map((y) => {
|
|
71
|
+
const x = (y - axis.from) * axis.pxPerYear;
|
|
72
|
+
return (_jsxs("g", { children: [_jsx("line", { className: "op-axis-tick", x1: x, x2: x, y1: 18, y2: axis.undatedY ?? 100000 }), _jsx("text", { className: "op-sublabel", x: x, y: 12, textAnchor: "middle", children: y })] }, y));
|
|
73
|
+
})] }));
|
|
74
|
+
}
|
|
75
|
+
function groupBox(group, nodes, pad = 10) {
|
|
76
|
+
const ns = group.occurrenceIds.map((id) => nodes.get(id)).filter((n) => Boolean(n));
|
|
77
|
+
if (!ns.length)
|
|
78
|
+
return null;
|
|
79
|
+
const x = Math.min(...ns.map((n) => n.x)) - pad;
|
|
80
|
+
const y = Math.min(...ns.map((n) => n.y)) - pad;
|
|
81
|
+
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 };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A pedigree view as SVG. Renders on the server with no client JavaScript;
|
|
85
|
+
* links stay ordinary links. Every node carries stable `.op-*` classes and
|
|
86
|
+
* `data-*` attributes for styling; color is never the only signal.
|
|
87
|
+
*/
|
|
88
|
+
export function PedigreeView(props) {
|
|
89
|
+
const { projection: p, layout, interactive } = props;
|
|
90
|
+
const terms = { ...DEFAULT_TERMS, ...props.terminology };
|
|
91
|
+
const pad = props.padding ?? 24;
|
|
92
|
+
const id = props.idPrefix ?? `op-${p.view}-${p.focalId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
|
|
93
|
+
const box = props.viewBox ?? { x: -pad, y: -pad, width: layout.bounds.width + 2 * pad, height: layout.bounds.height + 2 * pad };
|
|
94
|
+
const nodes = new Map(layout.nodes.map((n) => [n.occurrenceId, n]));
|
|
95
|
+
const occById = new Map(p.occurrences.map((o) => [o.occurrenceId, o]));
|
|
96
|
+
const title = props.title ?? `${VIEW_LABELS[p.view]}: ${labelOf(p.individuals[p.focalId], terms)}`;
|
|
97
|
+
const summary = describeProjection(p, terms);
|
|
98
|
+
const c = props.classNames ?? {};
|
|
99
|
+
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
|
|
100
|
+
.filter((g) => g.kind === 'cohort')
|
|
101
|
+
.map((g) => {
|
|
102
|
+
const b = groupBox(g, nodes);
|
|
103
|
+
if (!b)
|
|
104
|
+
return null;
|
|
105
|
+
return (_jsx("g", { className: cx('op-cohort', c.cohort), "data-cohort": g.id.replace(/^cohort:/, '').replace(/#.*$/, ''), 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));
|
|
106
|
+
}), layout.axis && _jsx(TimelineAxis, { axis: layout.axis, width: layout.bounds.width }), _jsx("g", { className: "op-connectors", children: layout.edges.map((e) => {
|
|
107
|
+
const child = occById.get(e.target);
|
|
108
|
+
const s = child ? p.structures[child.individualId] : undefined;
|
|
109
|
+
const highlighted = props.highlightedOccurrenceIds?.has(e.source) && props.highlightedOccurrenceIds?.has(e.target);
|
|
110
|
+
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));
|
|
111
|
+
}) }), _jsx("g", { className: "op-nodes", children: layout.nodes.map((node) => {
|
|
112
|
+
const occurrence = occById.get(node.occurrenceId);
|
|
113
|
+
if (!occurrence)
|
|
114
|
+
return null;
|
|
115
|
+
const individual = p.individuals[occurrence.individualId];
|
|
116
|
+
const structure = p.structures[occurrence.individualId];
|
|
117
|
+
const selected = props.selectedIndividualId === occurrence.individualId;
|
|
118
|
+
const highlighted = Boolean(props.highlightedOccurrenceIds?.has(node.occurrenceId) || occurrence.highlighted);
|
|
119
|
+
const href = individual ? props.getIndividualHref?.(individual) : null;
|
|
120
|
+
const label = labelOf(individual, terms);
|
|
121
|
+
const aria = [
|
|
122
|
+
label,
|
|
123
|
+
individual?.sex,
|
|
124
|
+
lifeLabel(lifeSpan(individual)) || null,
|
|
125
|
+
occurrence.ahnentafel !== undefined ? `Ahnentafel ${occurrence.ahnentafel}` : null,
|
|
126
|
+
occurrence.repeated ? terms.repeated : null,
|
|
127
|
+
describeStructure(structure),
|
|
128
|
+
props.geneOverlay ? `${props.geneOverlay.label}: ${genotypeWord(props.geneOverlay.marks.get(occurrence.individualId))}` : null,
|
|
129
|
+
props.genotypes ? genotypeText(props.genotypes, occurrence.individualId) : null,
|
|
130
|
+
]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(', ');
|
|
133
|
+
const slot = { individual, occurrence, node, structure, selected, highlighted, terms, ...(props.geneOverlay ? { gene: props.geneOverlay.marks.get(occurrence.individualId) ?? null } : {}), ...(props.genotypes ? { genotypes: props.genotypes } : {}) };
|
|
134
|
+
const content = node.arc ? (_jsxs(_Fragment, { children: [_jsx("path", { className: cx('op-fan-segment', c.fanSegment), d: arcPath(node.arc) }), _jsx(FanLabel, { arc: node.arc, label: label, years: nodeSublabel(individual), id: `${id}-arc-${node.occurrenceId.replace(/[^a-zA-Z0-9_-]/g, '_')}` })] })) : (_jsx("g", { transform: `translate(${node.x} ${node.y})`, children: props.renderIndividual ? props.renderIndividual(slot) : _jsx(DefaultIndividual, { ...slot }) }));
|
|
135
|
+
const attrs = {
|
|
136
|
+
className: cx('op-node', 'op-individual', c.node),
|
|
137
|
+
'data-individual': occurrence.individualId,
|
|
138
|
+
'data-occurrence': occurrence.occurrenceId,
|
|
139
|
+
'data-generation': occurrence.generation,
|
|
140
|
+
'data-repeated': occurrence.repeated || undefined,
|
|
141
|
+
'data-collapsed': occurrence.collapsedInto ? true : undefined,
|
|
142
|
+
'data-cohort': individual?.parentage?.cohort ?? undefined,
|
|
143
|
+
'data-reproductive-mode': structure?.reproductiveMode ?? undefined,
|
|
144
|
+
'data-sex': individual?.sex ?? undefined,
|
|
145
|
+
'data-focal': occurrence.individualId === p.focalId || undefined,
|
|
146
|
+
'data-selected': selected || undefined,
|
|
147
|
+
'data-highlighted': highlighted || undefined,
|
|
148
|
+
};
|
|
149
|
+
if (interactive)
|
|
150
|
+
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));
|
|
151
|
+
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));
|
|
152
|
+
}) })] }));
|
|
153
|
+
}
|
|
@@ -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;
|
package/dist/describe.js
ADDED
|
@@ -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,66 @@
|
|
|
1
|
+
import type { GenotypeRecord, InheritanceMode, SexClass } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
/** One individual's state for the gene being shown (from its `GenotypeRecord`). */
|
|
3
|
+
export type GenotypeMark = Pick<GenotypeRecord, 'state' | 'probability' | 'basis'>;
|
|
4
|
+
/** Shows one heritable gene on every node with the usual genetic pedigree symbols. */
|
|
5
|
+
export type GeneOverlay = {
|
|
6
|
+
/** The gene's name (legend title). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Marks by individual id; individuals without one are drawn as unknown. */
|
|
9
|
+
marks: ReadonlyMap<string, GenotypeMark>;
|
|
10
|
+
/** Recessive genes show het symbols; others show single/super. */
|
|
11
|
+
mode?: 'recessive' | 'incomplete-dominant' | 'dominant' | 'codominant' | 'polygenic' | 'unknown';
|
|
12
|
+
};
|
|
13
|
+
export type GenotypeWords = Record<'visual' | 'single' | 'super' | 'het' | 'claimedHet' | 'possibleHet' | 'nonCarrier' | 'unknown' | 'inferred', string>;
|
|
14
|
+
export declare const GENOTYPE_WORDS: GenotypeWords;
|
|
15
|
+
export declare function genotypeWord(mark: GenotypeMark | undefined, words?: GenotypeWords): string;
|
|
16
|
+
/** The symbol (SVG, centered on 0,0). */
|
|
17
|
+
export declare function GenotypeGlyph({ mark, words }: {
|
|
18
|
+
mark: GenotypeMark | undefined;
|
|
19
|
+
words?: GenotypeWords;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
21
|
+
/** Legend for the overlay: every symbol with its meaning (also the tooltips). */
|
|
22
|
+
export declare function GeneLegend({ overlay, words }: {
|
|
23
|
+
overlay: GeneOverlay;
|
|
24
|
+
words?: GenotypeWords;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
26
|
+
/** A gene shown as allele dots, with its own color (to tell genes apart; meaning comes from shape). */
|
|
27
|
+
export type GeneKey = {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
color: string;
|
|
31
|
+
mode?: InheritanceMode;
|
|
32
|
+
linkage?: 'Z' | 'X';
|
|
33
|
+
};
|
|
34
|
+
/** Several genes on every node: two dots per gene, one per copy. */
|
|
35
|
+
export type GenotypeDisplay = {
|
|
36
|
+
/** Genes to show, in order (up to four fit on a node). */
|
|
37
|
+
genes: readonly GeneKey[];
|
|
38
|
+
/** Each individual's records; a gene without a record is drawn as unknown. */
|
|
39
|
+
records: ReadonlyMap<string, readonly GenotypeRecord[]>;
|
|
40
|
+
/** Needed for sex-linked genes: the heterogametic sex has one copy (one dot). */
|
|
41
|
+
sexOf?: (individualId: string) => SexClass;
|
|
42
|
+
};
|
|
43
|
+
/** Allele dots for one gene (SVG, starting at 0,0, vertically centered on 0). */
|
|
44
|
+
export declare function GenotypeDots({ gene, record, hemizygous, r, words }: {
|
|
45
|
+
gene: GeneKey;
|
|
46
|
+
record: GenotypeRecord | undefined;
|
|
47
|
+
hemizygous?: boolean;
|
|
48
|
+
r?: number;
|
|
49
|
+
words?: GenotypeWords;
|
|
50
|
+
}): import("react").JSX.Element;
|
|
51
|
+
/** Width of one gene's dots plus the gap after it. */
|
|
52
|
+
export declare const dotsWidth: (r?: number) => number;
|
|
53
|
+
/** The genotypes line for one individual (several genes side by side). */
|
|
54
|
+
export declare function GenotypeStrip({ display, individualId, r, max }: {
|
|
55
|
+
display: GenotypeDisplay;
|
|
56
|
+
individualId: string;
|
|
57
|
+
r?: number;
|
|
58
|
+
max?: number;
|
|
59
|
+
}): import("react").JSX.Element;
|
|
60
|
+
/** Plain-language genotypes for an individual (screen readers, tooltips). */
|
|
61
|
+
export declare function genotypeText(display: GenotypeDisplay, individualId: string, words?: GenotypeWords): string;
|
|
62
|
+
/** Key for allele dots: each gene's color, and what the dots mean. */
|
|
63
|
+
export declare function GenotypeKey({ display, labels }: {
|
|
64
|
+
display: GenotypeDisplay;
|
|
65
|
+
labels?: Partial<Record<'title' | 'two' | 'one' | 'none' | 'possible' | 'unknown' | 'suggested', string>>;
|
|
66
|
+
}): import("react").JSX.Element;
|
package/dist/genetics.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export const GENOTYPE_WORDS = {
|
|
3
|
+
visual: 'Visual: shows it',
|
|
4
|
+
single: 'One copy (shows)',
|
|
5
|
+
super: 'Two copies ("super")',
|
|
6
|
+
het: 'Het: carries one copy, proven',
|
|
7
|
+
claimedHet: 'Het: carries one copy, not proven',
|
|
8
|
+
possibleHet: 'Possible het (chance shown)',
|
|
9
|
+
nonCarrier: 'Does not carry it',
|
|
10
|
+
unknown: 'Unknown',
|
|
11
|
+
inferred: 'suggested from relatives',
|
|
12
|
+
};
|
|
13
|
+
export function genotypeWord(mark, words = GENOTYPE_WORDS) {
|
|
14
|
+
if (!mark)
|
|
15
|
+
return words.unknown;
|
|
16
|
+
const w = mark.state === 'visual' ? words.visual
|
|
17
|
+
: mark.state === 'single' ? words.single
|
|
18
|
+
: mark.state === 'super' ? words.super
|
|
19
|
+
: mark.state === 'het' ? (mark.basis === 'proven-breeding' || mark.basis === 'genetic-test' ? words.het : words.claimedHet)
|
|
20
|
+
: mark.state === 'possible-het' ? `${words.possibleHet}${mark.probability !== undefined ? `: ${Math.round(mark.probability * 100)}%` : ''}`
|
|
21
|
+
: mark.state === 'non-carrier' ? words.nonCarrier
|
|
22
|
+
: words.unknown;
|
|
23
|
+
return mark.basis === 'inferred' ? `${w} (${words.inferred})` : w;
|
|
24
|
+
}
|
|
25
|
+
const R = 6;
|
|
26
|
+
/** The symbol (SVG, centered on 0,0). */
|
|
27
|
+
export function GenotypeGlyph({ mark, words }) {
|
|
28
|
+
const state = mark?.state ?? 'unknown';
|
|
29
|
+
const proven = mark?.basis === 'proven-breeding' || mark?.basis === 'genetic-test';
|
|
30
|
+
const half = `M 0 ${-R} A ${R} ${R} 0 0 0 0 ${R} Z`;
|
|
31
|
+
return (_jsxs("g", { className: "op-gene", "data-state": state, "data-basis": mark?.basis, "aria-hidden": "true", children: [_jsx("title", { children: genotypeWord(mark, words) }), state === 'super' && _jsx("circle", { className: "op-gene-ring", r: R + 3 }), _jsx("circle", { className: "op-gene-outline", r: R, "data-dashed": (state === 'het' && !proven) || state === 'unknown' || mark?.basis === 'inferred' || undefined }), (state === 'visual' || state === 'single' || state === 'super') && _jsx("circle", { className: "op-gene-fill", r: R }), state === 'het' && _jsx("path", { className: "op-gene-fill", d: half }), state === 'possible-het' && _jsx("circle", { className: "op-gene-fill", r: 2 }), state === 'possible-het' && mark?.probability !== undefined && (_jsxs("text", { className: "op-gene-text", x: -R - 3, y: 3.5, textAnchor: "end", children: [Math.round(mark.probability * 100), "%"] })), state === 'unknown' && (_jsx("text", { className: "op-gene-text", y: 3.5, textAnchor: "middle", children: "?" }))] }));
|
|
32
|
+
}
|
|
33
|
+
/** Legend for the overlay: every symbol with its meaning (also the tooltips). */
|
|
34
|
+
export function GeneLegend({ overlay, words = GENOTYPE_WORDS }) {
|
|
35
|
+
const recessive = (overlay.mode ?? 'recessive') === 'recessive';
|
|
36
|
+
const items = recessive
|
|
37
|
+
? [{ state: 'visual' }, { state: 'het', basis: 'proven-breeding' }, { state: 'het', basis: 'reported' }, { state: 'possible-het', probability: 0.66 }, { state: 'non-carrier' }, { state: 'unknown' }]
|
|
38
|
+
: [{ state: 'single' }, { state: 'super' }, { state: 'non-carrier' }, { state: 'unknown' }];
|
|
39
|
+
return (_jsxs("div", { className: "op-gene-legend", role: "group", "aria-label": overlay.label, children: [_jsx("strong", { children: overlay.label }), _jsx("ul", { children: items.map((m, i) => (_jsxs("li", { children: [_jsx("svg", { width: m.state === 'possible-het' ? 44 : 22, height: 22, viewBox: m.state === 'possible-het' ? '-33 -11 44 22' : '-11 -11 22 22', "aria-hidden": "true", children: _jsx(GenotypeGlyph, { mark: m, words: words }) }), genotypeWord(m, words)] }, i))) })] }));
|
|
40
|
+
}
|
|
41
|
+
/** How many of the two copies carry the variant: [filled, possible, unknown] out of `slots`. */
|
|
42
|
+
function copies(record, mode, slots) {
|
|
43
|
+
const state = record?.state ?? 'unknown';
|
|
44
|
+
if (state === 'unknown')
|
|
45
|
+
return { filled: 0, possible: 0, unknown: slots };
|
|
46
|
+
if (state === 'non-carrier')
|
|
47
|
+
return { filled: 0, possible: 0, unknown: 0 };
|
|
48
|
+
if (state === 'possible-het')
|
|
49
|
+
return { filled: 0, possible: 1, unknown: 0 };
|
|
50
|
+
if (state === 'het' || state === 'single')
|
|
51
|
+
return { filled: 1, possible: 0, unknown: 0 };
|
|
52
|
+
if (state === 'super')
|
|
53
|
+
return { filled: slots, possible: 0, unknown: 0 };
|
|
54
|
+
// visual: two copies for a recessive (or one when hemizygous); one assumed otherwise
|
|
55
|
+
return { filled: slots === 1 || mode === 'recessive' ? slots : 1, possible: 0, unknown: 0 };
|
|
56
|
+
}
|
|
57
|
+
/** Allele dots for one gene (SVG, starting at 0,0, vertically centered on 0). */
|
|
58
|
+
export function GenotypeDots({ gene, record, hemizygous = false, r = 4.5, words }) {
|
|
59
|
+
const slots = hemizygous ? 1 : 2;
|
|
60
|
+
const c = copies(record, gene.mode, slots);
|
|
61
|
+
const dots = [];
|
|
62
|
+
for (let i = 0; i < c.filled; i++)
|
|
63
|
+
dots.push('filled');
|
|
64
|
+
for (let i = 0; i < c.possible; i++)
|
|
65
|
+
dots.push('possible');
|
|
66
|
+
for (let i = 0; i < c.unknown; i++)
|
|
67
|
+
dots.push('unknown');
|
|
68
|
+
while (dots.length < slots)
|
|
69
|
+
dots.push('empty');
|
|
70
|
+
return (_jsxs("g", { className: "op-dots", "data-gene": gene.id, "aria-hidden": "true", children: [_jsx("title", { children: `${gene.label}: ${genotypeWord(record ? { state: record.state, ...(record.probability !== undefined ? { probability: record.probability } : {}), ...(record.basis ? { basis: record.basis } : {}) } : undefined, words)}` }), dots.map((d, i) => (_jsx("circle", { className: "op-dot", "data-dot": d, "data-inferred": d === 'filled' && record?.basis === 'inferred' ? '' : undefined, cx: r + i * (2 * r + 2), cy: 0, r: r, style: d === 'filled' ? { fill: gene.color } : undefined }, i)))] }));
|
|
71
|
+
}
|
|
72
|
+
/** Width of one gene's dots plus the gap after it. */
|
|
73
|
+
export const dotsWidth = (r = 4.5) => 2 * (2 * r) + 2 + 8;
|
|
74
|
+
/** The genotypes line for one individual (several genes side by side). */
|
|
75
|
+
export function GenotypeStrip({ display, individualId, r = 4.5, max = 4 }) {
|
|
76
|
+
const recs = display.records.get(individualId) ?? [];
|
|
77
|
+
const sex = display.sexOf?.(individualId) ?? 'unknown';
|
|
78
|
+
return (_jsx("g", { className: "op-genotypes", children: display.genes.slice(0, max).map((g, i) => (_jsx("g", { transform: `translate(${i * dotsWidth(r)} 0)`, children: _jsx(GenotypeDots, { gene: g, record: recs.find((x) => x.gene === g.id), hemizygous: Boolean(g.linkage) && sex === 'heterogametic', r: r }) }, g.id))) }));
|
|
79
|
+
}
|
|
80
|
+
/** Plain-language genotypes for an individual (screen readers, tooltips). */
|
|
81
|
+
export function genotypeText(display, individualId, words = GENOTYPE_WORDS) {
|
|
82
|
+
const recs = display.records.get(individualId) ?? [];
|
|
83
|
+
return display.genes
|
|
84
|
+
.map((g) => {
|
|
85
|
+
const r = recs.find((x) => x.gene === g.id);
|
|
86
|
+
return `${g.label}: ${genotypeWord(r ? { state: r.state, ...(r.probability !== undefined ? { probability: r.probability } : {}), ...(r.basis ? { basis: r.basis } : {}) } : undefined, words)}`;
|
|
87
|
+
})
|
|
88
|
+
.join('; ');
|
|
89
|
+
}
|
|
90
|
+
/** Key for allele dots: each gene's color, and what the dots mean. */
|
|
91
|
+
export function GenotypeKey({ display, labels = {} }) {
|
|
92
|
+
const l = { title: 'Genes', two: 'two copies', one: 'one copy', none: 'none', possible: 'possible (dashed)', unknown: 'not known', suggested: 'suggested from relatives', ...labels };
|
|
93
|
+
const demo = { id: 'demo', label: '', color: '#7a625c', mode: 'recessive' };
|
|
94
|
+
const sample = (record, text) => (_jsxs("li", { children: [_jsx("svg", { width: 22, height: 12, viewBox: "0 -6 22 12", "aria-hidden": "true", children: _jsx(GenotypeDots, { gene: demo, record: record, r: 4 }) }), text] }));
|
|
95
|
+
return (_jsxs("div", { className: "op-gene-legend op-genotype-key", role: "group", "aria-label": l.title, children: [_jsx("strong", { children: l.title }), _jsx("ul", { children: display.genes.map((g) => (_jsxs("li", { children: [_jsx("span", { className: "op-gene-swatch", style: { background: g.color }, "aria-hidden": "true" }), g.label] }, g.id))) }), _jsxs("ul", { children: [sample({ gene: 'demo', state: 'super' }, l.two), sample({ gene: 'demo', state: 'het' }, l.one), sample({ gene: 'demo', state: 'het', basis: 'inferred' }, l.suggested), sample({ gene: 'demo', state: 'non-carrier' }, l.none), sample({ gene: 'demo', state: 'possible-het' }, l.possible), sample(undefined, l.unknown)] })] }));
|
|
96
|
+
}
|
|
@@ -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';
|
package/dist/headless.js
ADDED
|
@@ -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';
|
package/dist/hooks.d.ts
ADDED
|
@@ -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
|
+
}
|