@captiveorigins/onepedigree-react 0.1.0-alpha.6 → 0.1.0-alpha.9
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/README.md +17 -0
- package/dist/AhnentafelList.d.ts +26 -0
- package/dist/AhnentafelList.js +36 -0
- package/dist/PedigreeExplorer.d.ts +16 -1
- package/dist/PedigreeExplorer.js +101 -6
- package/dist/PedigreeView.js +54 -4
- package/dist/icons.d.ts +33 -0
- package/dist/icons.js +31 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/reorder.d.ts +14 -0
- package/dist/reorder.js +28 -0
- package/dist/styles.css +26 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -26,6 +26,19 @@ All 13 views of the core: `pedigree`, `fan`, `ahnentafel`, `descendants`, `hourg
|
|
|
26
26
|
`paternal`. Repeated individuals follow the projection's `merge`, `separate` or `hybrid` mode;
|
|
27
27
|
selecting one occurrence highlights every occurrence of that individual.
|
|
28
28
|
|
|
29
|
+
## Rearranging by drag
|
|
30
|
+
|
|
31
|
+
In views where order is a choice (descendants, hourglass, cohort, population, relationship), people can drag a node sideways to reorder it among its siblings. Ancestor charts keep the father/mother convention. A node only moves within its own drawn groups (a clutch or litter frame) and among individuals with the same parents, so frames and family lines stay truthful. The data never changes: the order is a layout option you keep (and may save).
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
const [nodeOrder, setNodeOrder] = useState<string[]>([])
|
|
35
|
+
const layout = useMemo(() => layoutSync(projection, { nodeOrder }), [projection, nodeOrder])
|
|
36
|
+
|
|
37
|
+
<PedigreeExplorer projection={projection} layout={layout} nodeOrder={nodeOrder} onNodeOrderChange={setNodeOrder} />
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`dropNodeOrder(layout, occurrenceId, delta, current, groupOf)` is exported for custom UIs.
|
|
41
|
+
|
|
29
42
|
## Themes and colors
|
|
30
43
|
|
|
31
44
|
Seven presets, each with its own per-view accents (e.g. warm maternal and cool paternal
|
|
@@ -43,6 +56,10 @@ below WCAG AA. `onThemeChange` receives the edited theme (a plain object) to sto
|
|
|
43
56
|
back as `theme`. `ThemeEditor` is also exported for use in your own settings pages. Themes are
|
|
44
57
|
rendered as inline custom properties, so they work in server rendering.
|
|
45
58
|
|
|
59
|
+
## Brand family
|
|
60
|
+
|
|
61
|
+
OnePedigree and OneTaxonomy share a backbone (`brand/README.md`): the same line icons (`Icon`, `ICON_PATHS`), 32 px controls, a floating canvas toolbar and a Captive Origins orange focus ring, while each keeps its own views and character.
|
|
62
|
+
|
|
46
63
|
## Styling
|
|
47
64
|
|
|
48
65
|
- Default stylesheet `styles.css` (optional; leave it out for unstyled mode).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Individual, type PedigreeViewProjection } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
import { type Terminology } from './describe.js';
|
|
3
|
+
export type AhnentafelListProps = {
|
|
4
|
+
projection: PedigreeViewProjection;
|
|
5
|
+
terminology?: Partial<Terminology>;
|
|
6
|
+
/** Words for the two parents (default Father/Mother), e.g. Sire/Dam. */
|
|
7
|
+
parentWords?: {
|
|
8
|
+
paternal: string;
|
|
9
|
+
maternal: string;
|
|
10
|
+
};
|
|
11
|
+
getIndividualHref?: (individual: Individual) => string | null | undefined;
|
|
12
|
+
onSelect?: (individualId: string) => void;
|
|
13
|
+
selectedIndividualId?: string | null;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
/** "Father’s mother" for Ahnentafel number 6: each bit after the first is a step (0 = paternal, 1 = maternal). */
|
|
17
|
+
export declare function ahnentafelRelation(n: number, words?: {
|
|
18
|
+
paternal: string;
|
|
19
|
+
maternal: string;
|
|
20
|
+
}): string;
|
|
21
|
+
/**
|
|
22
|
+
* The Ahnentafel as genealogists use it: a numbered list of ancestors by
|
|
23
|
+
* generation (1 = the focal individual, 2n = father, 2n + 1 = mother), with
|
|
24
|
+
* life years and the relationship spelled out. Server-renderable.
|
|
25
|
+
*/
|
|
26
|
+
export declare function AhnentafelList(props: AhnentafelListProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { lifeLabel, lifeSpan } from '@captiveorigins/onepedigree-core';
|
|
3
|
+
import { DEFAULT_TERMS, labelOf } from './describe.js';
|
|
4
|
+
/** "Father’s mother" for Ahnentafel number 6: each bit after the first is a step (0 = paternal, 1 = maternal). */
|
|
5
|
+
export function ahnentafelRelation(n, words = { paternal: 'Father', maternal: 'Mother' }) {
|
|
6
|
+
if (n <= 1)
|
|
7
|
+
return '';
|
|
8
|
+
const steps = n
|
|
9
|
+
.toString(2)
|
|
10
|
+
.slice(1)
|
|
11
|
+
.split('')
|
|
12
|
+
.map((b) => (b === '0' ? words.paternal : words.maternal));
|
|
13
|
+
return steps.map((w, i) => (i === 0 ? w : w.toLowerCase())).join('’s ');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The Ahnentafel as genealogists use it: a numbered list of ancestors by
|
|
17
|
+
* generation (1 = the focal individual, 2n = father, 2n + 1 = mother), with
|
|
18
|
+
* life years and the relationship spelled out. Server-renderable.
|
|
19
|
+
*/
|
|
20
|
+
export function AhnentafelList(props) {
|
|
21
|
+
const terms = { ...DEFAULT_TERMS, ...props.terminology };
|
|
22
|
+
const rows = props.projection.occurrences.filter((o) => o.ahnentafel !== undefined).sort((a, b) => a.ahnentafel - b.ahnentafel);
|
|
23
|
+
const generations = new Map();
|
|
24
|
+
for (const r of rows) {
|
|
25
|
+
const g = Math.floor(Math.log2(r.ahnentafel)) + 1;
|
|
26
|
+
generations.set(g, [...(generations.get(g) ?? []), r]);
|
|
27
|
+
}
|
|
28
|
+
const unnumbered = props.projection.occurrences.filter((o) => o.ahnentafel === undefined);
|
|
29
|
+
return (_jsxs("div", { className: ['op-ahnentafel', props.className].filter(Boolean).join(' '), children: [[...generations].map(([g, list]) => (_jsxs("section", { className: "op-ahnentafel-generation", "aria-label": `Generation ${g}`, children: [_jsxs("h3", { children: ["Generation ", g] }), _jsx("ol", { children: list.map((o) => {
|
|
30
|
+
const individual = props.projection.individuals[o.individualId];
|
|
31
|
+
const href = individual ? props.getIndividualHref?.(individual) : null;
|
|
32
|
+
const name = labelOf(individual, terms);
|
|
33
|
+
const years = lifeLabel(lifeSpan(individual));
|
|
34
|
+
return (_jsxs("li", { value: o.ahnentafel, "data-selected": props.selectedIndividualId === o.individualId || undefined, children: [_jsxs("span", { className: "op-ahnentafel-number", children: [o.ahnentafel, "."] }), ' ', href ? (_jsx("a", { href: href, children: name })) : props.onSelect ? (_jsx("button", { type: "button", onClick: () => props.onSelect(o.individualId), children: name })) : (_jsx("span", { children: name })), years && _jsxs("span", { className: "op-ahnentafel-years", children: [" ", years] }), o.ahnentafel > 1 && _jsxs("span", { className: "op-ahnentafel-relation", children: [" \u00B7 ", ahnentafelRelation(o.ahnentafel, props.parentWords)] })] }, o.occurrenceId));
|
|
35
|
+
}) })] }, g))), unnumbered.length > 0 && (_jsxs("p", { className: "op-ahnentafel-note", children: [unnumbered.length, " ", unnumbered.length === 1 ? terms.individual : terms.individuals, " without a mother/father role are not numbered."] }))] }));
|
|
36
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Individual, type IndividualOccurrence } from '@captiveorigins/onepedigree-core';
|
|
2
2
|
import { type ReactNode } from 'react';
|
|
3
3
|
import { type PedigreeViewProps } from './PedigreeView.js';
|
|
4
4
|
import { type PedigreeTheme } from './themes.js';
|
|
@@ -20,6 +20,21 @@ export type PedigreeExplorerProps = Omit<PedigreeViewProps, 'interactive' | 'vie
|
|
|
20
20
|
themeEditor?: boolean;
|
|
21
21
|
/** Called with the edited theme (e.g. to save it). */
|
|
22
22
|
onThemeChange?: (theme: PedigreeTheme) => void;
|
|
23
|
+
/** Print the chart (default: the browser's print, showing only the chart). Desktop shells can supply their own. */
|
|
24
|
+
onPrint?: () => void;
|
|
25
|
+
/** Ahnentafel words for the two parents (default Father/Mother). */
|
|
26
|
+
parentWords?: {
|
|
27
|
+
paternal: string;
|
|
28
|
+
maternal: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Turns on click-and-drag rearranging in views where order is a choice
|
|
32
|
+
* (REORDERABLE_VIEWS: descendants, hourglass, cohort, population, relationship).
|
|
33
|
+
* Called with the new order; pass it back as `layoutSync(projection, { nodeOrder })`.
|
|
34
|
+
*/
|
|
35
|
+
onNodeOrderChange?: (nodeOrder: string[]) => void;
|
|
36
|
+
/** The current chosen order (what you passed to the layout), so a drag extends it. */
|
|
37
|
+
nodeOrder?: readonly string[];
|
|
23
38
|
/** Accessible labels for the controls. */
|
|
24
39
|
labels?: Partial<Record<'zoomIn' | 'zoomOut' | 'fit' | 'download' | 'print' | 'close' | 'open' | 'controls' | 'colors', string>>;
|
|
25
40
|
};
|
package/dist/PedigreeExplorer.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { REORDERABLE_VIEWS } from '@captiveorigins/onepedigree-core';
|
|
3
4
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import { AhnentafelList } from './AhnentafelList.js';
|
|
4
6
|
import { DEFAULT_TERMS, describeStructure, labelOf } from './describe.js';
|
|
5
7
|
import { nearestNode, useOccurrenceHighlighting, usePedigreeSelection } from './hooks.js';
|
|
6
8
|
import { PedigreeView } from './PedigreeView.js';
|
|
9
|
+
import { Icon } from './icons.js';
|
|
10
|
+
import { dropNodeOrder } from './reorder.js';
|
|
7
11
|
import { ThemeEditor } from './ThemeEditor.js';
|
|
8
12
|
import { presetTheme, THEME_PRESETS, themeStyle } from './themes.js';
|
|
9
13
|
const LABELS = {
|
|
@@ -17,6 +21,22 @@ const LABELS = {
|
|
|
17
21
|
controls: 'Pedigree controls',
|
|
18
22
|
colors: 'Colors',
|
|
19
23
|
};
|
|
24
|
+
/** Properties copied into downloaded SVGs (they have no stylesheet). */
|
|
25
|
+
const INLINE = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-opacity', 'opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'visibility', 'display'];
|
|
26
|
+
/** Prints only this chart: everything else on the page is hidden while printing. */
|
|
27
|
+
function printChart(el) {
|
|
28
|
+
if (!el)
|
|
29
|
+
return window.print();
|
|
30
|
+
el.setAttribute('data-op-printing', '');
|
|
31
|
+
document.documentElement.setAttribute('data-op-printing', '');
|
|
32
|
+
const done = () => {
|
|
33
|
+
el.removeAttribute('data-op-printing');
|
|
34
|
+
document.documentElement.removeAttribute('data-op-printing');
|
|
35
|
+
window.removeEventListener('afterprint', done);
|
|
36
|
+
};
|
|
37
|
+
window.addEventListener('afterprint', done);
|
|
38
|
+
window.print();
|
|
39
|
+
}
|
|
20
40
|
const KEY_DIRECTION = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
|
|
21
41
|
/**
|
|
22
42
|
* Interactive pedigree: pan (drag, Shift+arrows), zoom (wheel, +/-, buttons),
|
|
@@ -48,6 +68,10 @@ export function PedigreeExplorer(props) {
|
|
|
48
68
|
props.onThemeChange?.(t);
|
|
49
69
|
};
|
|
50
70
|
const drag = useRef(null);
|
|
71
|
+
// Dragging a node (rearranging) moves the element directly while the pointer is down: no re-render per frame.
|
|
72
|
+
const nodeDrag = useRef(null);
|
|
73
|
+
const suppressClick = useRef(false);
|
|
74
|
+
const reorderable = Boolean(props.onNodeOrderChange) && REORDERABLE_VIEWS.has(projection.view);
|
|
51
75
|
const choose = useCallback((occ) => {
|
|
52
76
|
select(occ?.individualId ?? null);
|
|
53
77
|
setSelectedOccurrence(occ?.occurrenceId ?? null);
|
|
@@ -66,7 +90,7 @@ export function PedigreeExplorer(props) {
|
|
|
66
90
|
}, [full]);
|
|
67
91
|
const pan = (dx, dy) => setBox((b) => ({ ...b, x: b.x + dx * b.width, y: b.y + dy * b.height }));
|
|
68
92
|
const svgPoint = (e) => {
|
|
69
|
-
const svg = root.current?.querySelector('svg');
|
|
93
|
+
const svg = root.current?.querySelector('svg.op-root');
|
|
70
94
|
const r = svg?.getBoundingClientRect();
|
|
71
95
|
if (!r || !r.width || !r.height)
|
|
72
96
|
return undefined;
|
|
@@ -111,28 +135,93 @@ export function PedigreeExplorer(props) {
|
|
|
111
135
|
return;
|
|
112
136
|
zoom(e.deltaY > 0 ? 1.1 : 0.9, svgPoint(e));
|
|
113
137
|
};
|
|
138
|
+
const svgScale = () => {
|
|
139
|
+
const r = root.current?.querySelector('svg.op-root')?.getBoundingClientRect();
|
|
140
|
+
return r?.width ? Math.max(box.width / r.width, box.height / r.height) : 1;
|
|
141
|
+
};
|
|
114
142
|
const onPointerDown = (e) => {
|
|
115
|
-
|
|
143
|
+
const target = e.target;
|
|
144
|
+
if (target.closest('button, a, .op-toolbar, .op-quick-view, .op-theme-panel'))
|
|
145
|
+
return;
|
|
146
|
+
const nodeEl = target.closest('.op-node');
|
|
147
|
+
if (nodeEl) {
|
|
148
|
+
const id = nodeEl.getAttribute('data-occurrence');
|
|
149
|
+
if (reorderable && id)
|
|
150
|
+
nodeDrag.current = { x: e.clientX, y: e.clientY, id, el: nodeEl, moved: false };
|
|
116
151
|
return;
|
|
152
|
+
}
|
|
117
153
|
drag.current = { x: e.clientX, y: e.clientY, box };
|
|
118
154
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
119
155
|
};
|
|
120
156
|
const onPointerMove = (e) => {
|
|
157
|
+
const n = nodeDrag.current;
|
|
158
|
+
if (n) {
|
|
159
|
+
if (!n.moved && Math.hypot(e.clientX - n.x, e.clientY - n.y) < 4)
|
|
160
|
+
return;
|
|
161
|
+
if (!n.moved)
|
|
162
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
163
|
+
n.moved = true;
|
|
164
|
+
const k = svgScale();
|
|
165
|
+
n.el.style.translate = `${(e.clientX - n.x) * k}px ${(e.clientY - n.y) * k}px`;
|
|
166
|
+
n.el.setAttribute('data-dragging', '');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
121
169
|
const d = drag.current;
|
|
122
|
-
const r = root.current?.querySelector('svg')?.getBoundingClientRect();
|
|
170
|
+
const r = root.current?.querySelector('svg.op-root')?.getBoundingClientRect();
|
|
123
171
|
if (!d || !r?.width)
|
|
124
172
|
return;
|
|
125
173
|
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
174
|
};
|
|
127
|
-
const onPointerUp = () => {
|
|
175
|
+
const onPointerUp = (e) => {
|
|
128
176
|
drag.current = null;
|
|
177
|
+
const n = nodeDrag.current;
|
|
178
|
+
nodeDrag.current = null;
|
|
179
|
+
if (!n?.moved)
|
|
180
|
+
return;
|
|
181
|
+
n.el.style.translate = '';
|
|
182
|
+
n.el.removeAttribute('data-dragging');
|
|
183
|
+
suppressClick.current = true;
|
|
184
|
+
const k = svgScale();
|
|
185
|
+
// Only among nodes in the same drawn groups (clutch/litter frames, other-parent groups) and with the same
|
|
186
|
+
// parents, so frames and family lines stay truthful.
|
|
187
|
+
const groupOf = (id) => [
|
|
188
|
+
...(projection.groups ?? []).filter((g) => g.occurrenceIds.includes(id)).map((g) => g.id),
|
|
189
|
+
projection.edges.filter((ed) => ed.target === id).map((ed) => ed.source).sort().join('+'),
|
|
190
|
+
].join('|');
|
|
191
|
+
const order = dropNodeOrder(layout, n.id, { dx: (e.clientX - n.x) * k, dy: (e.clientY - n.y) * k }, props.nodeOrder, groupOf);
|
|
192
|
+
if (order)
|
|
193
|
+
props.onNodeOrderChange(order);
|
|
194
|
+
};
|
|
195
|
+
const onPointerCancel = () => {
|
|
196
|
+
drag.current = null;
|
|
197
|
+
if (nodeDrag.current) {
|
|
198
|
+
nodeDrag.current.el.style.translate = '';
|
|
199
|
+
nodeDrag.current.el.removeAttribute('data-dragging');
|
|
200
|
+
}
|
|
201
|
+
nodeDrag.current = null;
|
|
129
202
|
};
|
|
130
203
|
const download = () => {
|
|
131
|
-
const svg = root.current?.querySelector('svg');
|
|
204
|
+
const svg = root.current?.querySelector('svg.op-root');
|
|
132
205
|
if (!svg)
|
|
133
206
|
return;
|
|
134
207
|
const clone = svg.cloneNode(true);
|
|
135
208
|
clone.setAttribute('viewBox', `${full.x} ${full.y} ${full.width} ${full.height}`);
|
|
209
|
+
clone.setAttribute('width', String(full.width));
|
|
210
|
+
clone.setAttribute('height', String(full.height));
|
|
211
|
+
// A downloaded file has no stylesheet: copy each element's computed colors and fonts into it.
|
|
212
|
+
const source = [svg, ...svg.querySelectorAll('*')];
|
|
213
|
+
const target = [clone, ...clone.querySelectorAll('*')];
|
|
214
|
+
source.forEach((el, i) => {
|
|
215
|
+
const cs = getComputedStyle(el);
|
|
216
|
+
target[i]?.setAttribute('style', INLINE.map((prop) => `${prop}:${cs.getPropertyValue(prop)}`).join(';'));
|
|
217
|
+
});
|
|
218
|
+
const bg = getComputedStyle(root.current).backgroundColor;
|
|
219
|
+
if (bg && bg !== 'rgba(0, 0, 0, 0)') {
|
|
220
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
221
|
+
for (const [k, v] of Object.entries({ x: full.x, y: full.y, width: full.width, height: full.height, fill: bg }))
|
|
222
|
+
rect.setAttribute(k, String(v));
|
|
223
|
+
clone.insertBefore(rect, clone.firstChild);
|
|
224
|
+
}
|
|
136
225
|
const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml' });
|
|
137
226
|
const a = document.createElement('a');
|
|
138
227
|
a.href = URL.createObjectURL(blob);
|
|
@@ -144,5 +233,11 @@ export function PedigreeExplorer(props) {
|
|
|
144
233
|
const individual = occurrence ? projection.individuals[occurrence.individualId] : undefined;
|
|
145
234
|
const href = individual ? (props.getIndividualHref?.(individual) ?? null) : null;
|
|
146
235
|
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:
|
|
236
|
+
return (_jsxs("div", { ref: root, className: "op-explorer", style: { height: props.height ?? '32rem', ...(theme ? { ...themeStyle(theme, projection.view), background: 'var(--op-background)' } : {}) }, onKeyDown: onKeyDown, onWheel: onWheel, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onClickCapture: (e) => {
|
|
237
|
+
// A drag ends with a click on the node: don't let it select.
|
|
238
|
+
if (suppressClick.current) {
|
|
239
|
+
suppressClick.current = false;
|
|
240
|
+
e.stopPropagation();
|
|
241
|
+
}
|
|
242
|
+
}, "data-reorderable": reorderable ? '' : undefined, children: [_jsxs("div", { className: "op-toolbar", role: "toolbar", "aria-label": labels.controls, children: [_jsx("button", { type: "button", onClick: () => zoom(0.8), "aria-label": labels.zoomIn, title: labels.zoomIn, children: _jsx(Icon, { name: "plus" }) }), _jsx("button", { type: "button", onClick: () => zoom(1.25), "aria-label": labels.zoomOut, title: labels.zoomOut, children: _jsx(Icon, { name: "minus" }) }), _jsx("button", { type: "button", onClick: () => setBox(full), "aria-label": labels.fit, title: labels.fit, children: _jsx(Icon, { name: "fit" }) }), _jsx("button", { type: "button", onClick: download, "aria-label": labels.download, title: labels.download, children: _jsx(Icon, { name: "download" }) }), _jsx("button", { type: "button", onClick: () => (props.onPrint ? props.onPrint() : printChart(root.current)), "aria-label": labels.print, title: labels.print, children: _jsx(Icon, { name: "print" }) }), props.themeEditor && (_jsx("button", { type: "button", onClick: () => setEditing((v) => !v), "aria-label": labels.colors, title: labels.colors, "aria-expanded": editing, children: _jsx(Icon, { name: "palette" }) })), props.toolbar] }), props.themeEditor && editing && (_jsx("div", { className: "op-theme-panel", children: _jsx(ThemeEditor, { value: theme ?? structuredClone(THEME_PRESETS[0]), view: projection.view, onChange: changeTheme }) })), projection.view === 'ahnentafel' ? (_jsx(AhnentafelList, { projection: projection, terminology: props.terminology, parentWords: props.parentWords, getIndividualHref: props.getIndividualHref, selectedIndividualId: selectedId, onSelect: (individualId) => choose(projection.occurrences.find((o) => o.individualId === individualId) ?? null) })) : (_jsx(PedigreeView, { ...props, theme: theme, interactive: true, viewBox: box, selectedIndividualId: selectedId, highlightedOccurrenceIds: highlighted, onNodeClick: (occ) => choose(selectedOccurrence === occ.occurrenceId ? null : occ), onNodeKeyDown: onNodeKeyDown, svgProps: { ...props.svgProps, width: '100%', height: '100%', preserveAspectRatio: 'xMidYMid meet' } })), occurrence && (_jsx("div", { className: "op-quick-view", role: "dialog", "aria-label": labelOf(individual, terms), children: props.renderQuickView ? (props.renderQuickView({ individual, occurrence, href, close })) : (_jsxs(_Fragment, { children: [_jsx("p", { className: "op-quick-view-title", children: labelOf(individual, terms) }), individual?.sex && _jsx("p", { children: individual.sex }), _jsx("p", { children: describeStructure(projection.structures[occurrence.individualId]) }), occurrence.repeated && _jsx("p", { children: terms.repeated }), _jsxs("div", { className: "op-quick-view-actions", children: [href && (_jsxs("a", { href: href, children: [_jsx(Icon, { name: "open" }), labels.open] })), _jsxs("button", { type: "button", onClick: close, children: [_jsx(Icon, { name: "close" }), labels.close] })] })] })) }))] }));
|
|
148
243
|
}
|
package/dist/PedigreeView.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
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';
|
|
2
3
|
import { arcPath, DEFAULT_TERMS, describeProjection, describeStructure, labelOf, VIEW_LABELS } from './describe.js';
|
|
3
4
|
import { themeStyle } from './themes.js';
|
|
4
5
|
const cx = (...c) => c.filter(Boolean).join(' ');
|
|
@@ -18,10 +19,58 @@ function Junction({ s, x, y }) {
|
|
|
18
19
|
}
|
|
19
20
|
function DefaultIndividual({ individual, occurrence, node, structure, terms }) {
|
|
20
21
|
const label = labelOf(individual, terms);
|
|
21
|
-
|
|
22
|
-
const
|
|
22
|
+
// Sex mark and life years ("♂ 1950 – 2010"), plus the Ahnentafel number where it applies.
|
|
23
|
+
const sub = [nodeSublabel(individual), occurrence.ahnentafel !== undefined ? `#${occurrence.ahnentafel}` : null].filter(Boolean).join(' · ');
|
|
24
|
+
// Nodes are sized to their names; names are only shortened when a host made the node narrower (or past the widest node).
|
|
25
|
+
const fits = node.width >= estimateNodeWidth(label, sub) - 1;
|
|
26
|
+
const chars = fits ? Infinity : Math.max(6, Math.floor((node.width - 20) / 7.4));
|
|
27
|
+
if (node.life)
|
|
28
|
+
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 })] }));
|
|
23
29
|
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
30
|
}
|
|
31
|
+
const RAD = 180 / Math.PI;
|
|
32
|
+
/**
|
|
33
|
+
* Fan labels follow the fan: along the arc in the wide inner rings (with life
|
|
34
|
+
* years when there is room), along the radius in the narrow outer ones; always
|
|
35
|
+
* upright, never upside down.
|
|
36
|
+
*/
|
|
37
|
+
function FanLabel({ arc, label, years, id }) {
|
|
38
|
+
const { cx: x0, cy: y0, innerRadius: r0, outerRadius: r1, startAngle: a0, endAngle: a1 } = arc;
|
|
39
|
+
if (r0 <= 0)
|
|
40
|
+
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 }))] }));
|
|
41
|
+
const mid = (a0 + a1) / 2;
|
|
42
|
+
const r = (r0 + r1) / 2;
|
|
43
|
+
const ring = r1 - r0;
|
|
44
|
+
const arcLength = r * (a1 - a0);
|
|
45
|
+
const pt = (radius, t) => `${(x0 + radius * Math.sin(t)).toFixed(2)} ${(y0 - radius * Math.cos(t)).toFixed(2)}`;
|
|
46
|
+
if (arcLength >= ring * 1.2) {
|
|
47
|
+
// Along the arc; on the lower half the path runs the other way so the text stays upright.
|
|
48
|
+
const lower = Math.cos(mid) < 0;
|
|
49
|
+
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)}`;
|
|
50
|
+
const yearsR = r + (Math.cos(mid) < 0 ? 9 : -9);
|
|
51
|
+
// Years only where they fit in the segment (never spilling into the next one).
|
|
52
|
+
const showYears = Boolean(years) && ring >= 46 && years.length * 6.4 + 8 <= yearsR * (a1 - a0);
|
|
53
|
+
const nameR = showYears ? r + (lower ? -7 : 7) : r + (lower ? -4 : 4);
|
|
54
|
+
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 }) }))] }));
|
|
55
|
+
}
|
|
56
|
+
// Along the radius, reading outward on the right half and inward on the left.
|
|
57
|
+
const left = Math.sin(mid) < 0;
|
|
58
|
+
const [px, py] = pt(r, mid).split(' ').map(Number);
|
|
59
|
+
const rotate = mid * RAD - 90 + (left ? 180 : 0);
|
|
60
|
+
const size = Math.min(13, Math.max(8, arcLength * 0.6));
|
|
61
|
+
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))) }));
|
|
62
|
+
}
|
|
63
|
+
/** The timeline's year axis: ticks every 1, 2, 5, 10, 25, 50 or 100 years, whichever keeps them readable. */
|
|
64
|
+
function TimelineAxis({ axis, width }) {
|
|
65
|
+
const step = [1, 2, 5, 10, 25, 50, 100, 250].find((s) => s * axis.pxPerYear >= 60) ?? 500;
|
|
66
|
+
const ticks = [];
|
|
67
|
+
for (let y = Math.ceil(axis.from / step) * step; y <= axis.to; y += step)
|
|
68
|
+
ticks.push(y);
|
|
69
|
+
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) => {
|
|
70
|
+
const x = (y - axis.from) * axis.pxPerYear;
|
|
71
|
+
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));
|
|
72
|
+
})] }));
|
|
73
|
+
}
|
|
25
74
|
function groupBox(group, nodes, pad = 10) {
|
|
26
75
|
const ns = group.occurrenceIds.map((id) => nodes.get(id)).filter((n) => Boolean(n));
|
|
27
76
|
if (!ns.length)
|
|
@@ -53,7 +102,7 @@ export function PedigreeView(props) {
|
|
|
53
102
|
if (!b)
|
|
54
103
|
return null;
|
|
55
104
|
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) => {
|
|
105
|
+
}), layout.axis && _jsx(TimelineAxis, { axis: layout.axis, width: layout.bounds.width }), _jsx("g", { className: "op-connectors", children: layout.edges.map((e) => {
|
|
57
106
|
const child = occById.get(e.target);
|
|
58
107
|
const s = child ? p.structures[child.individualId] : undefined;
|
|
59
108
|
const highlighted = props.highlightedOccurrenceIds?.has(e.source) && props.highlightedOccurrenceIds?.has(e.target);
|
|
@@ -71,6 +120,7 @@ export function PedigreeView(props) {
|
|
|
71
120
|
const aria = [
|
|
72
121
|
label,
|
|
73
122
|
individual?.sex,
|
|
123
|
+
lifeLabel(lifeSpan(individual)) || null,
|
|
74
124
|
occurrence.ahnentafel !== undefined ? `Ahnentafel ${occurrence.ahnentafel}` : null,
|
|
75
125
|
occurrence.repeated ? terms.repeated : null,
|
|
76
126
|
describeStructure(structure),
|
|
@@ -78,7 +128,7 @@ export function PedigreeView(props) {
|
|
|
78
128
|
.filter(Boolean)
|
|
79
129
|
.join(', ');
|
|
80
130
|
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(
|
|
131
|
+
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 }) }));
|
|
82
132
|
const attrs = {
|
|
83
133
|
className: cx('op-node', 'op-individual', c.node),
|
|
84
134
|
'data-individual': occurrence.individualId,
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SVGProps } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The brand family's line icons (brand/README.md): 24 × 24, 1.75 stroke, round
|
|
4
|
+
* caps and joins, currentColor. The same drawings ship in OnePedigree and
|
|
5
|
+
* OneTaxonomy. Decorative by default (aria-hidden): put the words in the button's
|
|
6
|
+
* text or aria-label.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ICON_PATHS: {
|
|
9
|
+
readonly plus: "M12 5v14M5 12h14";
|
|
10
|
+
readonly minus: "M5 12h14";
|
|
11
|
+
readonly fit: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5";
|
|
12
|
+
readonly download: "M12 4v11M7 10l5 5 5-5M5 20h14";
|
|
13
|
+
readonly print: "M7 9V4h10v5M7 17H5a1 1 0 0 1-1-1v-5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a1 1 0 0 1-1 1h-2M7 14h10v6H7z";
|
|
14
|
+
readonly undo: "M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11";
|
|
15
|
+
readonly redo: "m15 14 5-5-5-5M20 9H9.5a5.5 5.5 0 0 0 0 11H13";
|
|
16
|
+
readonly expand: "m7 15 5 5 5-5M7 9l5-5 5 5";
|
|
17
|
+
readonly collapse: "m7 20 5-5 5 5M7 4l5 5 5-5";
|
|
18
|
+
readonly flip: "M7 4v16M4 7l3-3 3 3M17 20V4m-3 13 3 3 3-3";
|
|
19
|
+
readonly focus: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM12 2v4M12 18v4M2 12h4M18 12h4";
|
|
20
|
+
readonly unfocus: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5M9 12h6";
|
|
21
|
+
readonly layout: "M4 12h4M8 6v12M8 6h5M8 18h5M8 12h9M15 4v4M15 16v4";
|
|
22
|
+
readonly filter: "M4 5h16l-6 8v6l-4 2v-8z";
|
|
23
|
+
readonly palette: "M12 3a9 9 0 1 0 0 18c1.1 0 1.5-.8 1.2-1.6-.4-1 .2-2.4 1.6-2.4H17a4 4 0 0 0 4-4c0-5.5-4-10-9-10zM7.5 11.5h.01M10 7.5h.01M15 7.5h.01";
|
|
24
|
+
readonly close: "M6 6l12 12M18 6 6 18";
|
|
25
|
+
readonly open: "M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5";
|
|
26
|
+
readonly grip: "M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01";
|
|
27
|
+
readonly info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v6M12 7.5h.01";
|
|
28
|
+
};
|
|
29
|
+
export type IconName = keyof typeof ICON_PATHS;
|
|
30
|
+
export declare function Icon({ name, size, className, ...rest }: {
|
|
31
|
+
name: IconName;
|
|
32
|
+
size?: number;
|
|
33
|
+
} & Omit<SVGProps<SVGSVGElement>, 'name'>): import("react").JSX.Element;
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The brand family's line icons (brand/README.md): 24 × 24, 1.75 stroke, round
|
|
4
|
+
* caps and joins, currentColor. The same drawings ship in OnePedigree and
|
|
5
|
+
* OneTaxonomy. Decorative by default (aria-hidden): put the words in the button's
|
|
6
|
+
* text or aria-label.
|
|
7
|
+
*/
|
|
8
|
+
export const ICON_PATHS = {
|
|
9
|
+
plus: 'M12 5v14M5 12h14',
|
|
10
|
+
minus: 'M5 12h14',
|
|
11
|
+
fit: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
|
|
12
|
+
download: 'M12 4v11M7 10l5 5 5-5M5 20h14',
|
|
13
|
+
print: 'M7 9V4h10v5M7 17H5a1 1 0 0 1-1-1v-5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a1 1 0 0 1-1 1h-2M7 14h10v6H7z',
|
|
14
|
+
undo: 'M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
|
|
15
|
+
redo: 'm15 14 5-5-5-5M20 9H9.5a5.5 5.5 0 0 0 0 11H13',
|
|
16
|
+
expand: 'm7 15 5 5 5-5M7 9l5-5 5 5',
|
|
17
|
+
collapse: 'm7 20 5-5 5 5M7 4l5 5 5-5',
|
|
18
|
+
flip: 'M7 4v16M4 7l3-3 3 3M17 20V4m-3 13 3 3 3-3',
|
|
19
|
+
focus: 'M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM12 2v4M12 18v4M2 12h4M18 12h4',
|
|
20
|
+
unfocus: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5M9 12h6',
|
|
21
|
+
layout: 'M4 12h4M8 6v12M8 6h5M8 18h5M8 12h9M15 4v4M15 16v4',
|
|
22
|
+
filter: 'M4 5h16l-6 8v6l-4 2v-8z',
|
|
23
|
+
palette: 'M12 3a9 9 0 1 0 0 18c1.1 0 1.5-.8 1.2-1.6-.4-1 .2-2.4 1.6-2.4H17a4 4 0 0 0 4-4c0-5.5-4-10-9-10zM7.5 11.5h.01M10 7.5h.01M15 7.5h.01',
|
|
24
|
+
close: 'M6 6l12 12M18 6 6 18',
|
|
25
|
+
open: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
|
26
|
+
grip: 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01',
|
|
27
|
+
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v6M12 7.5h.01',
|
|
28
|
+
};
|
|
29
|
+
export function Icon({ name, size = 16, className, ...rest }) {
|
|
30
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", className: ["op-icon", className].filter(Boolean).join(" "), ...rest, children: _jsx("path", { d: ICON_PATHS[name] }) }));
|
|
31
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,6 @@ export { PedigreeView, type CohortMarkerSlotProps, type IndividualSlotProps, typ
|
|
|
2
2
|
export { PedigreeExplorer, type PedigreeExplorerProps, type QuickViewProps } from './PedigreeExplorer.js';
|
|
3
3
|
export * from './headless.js';
|
|
4
4
|
export { ThemeEditor, type ThemeEditorProps } from './ThemeEditor.js';
|
|
5
|
+
export { AhnentafelList, ahnentafelRelation, type AhnentafelListProps } from './AhnentafelList.js';
|
|
6
|
+
export { Icon, ICON_PATHS, type IconName } from './icons.js';
|
|
7
|
+
export { dropNodeOrder } from './reorder.js';
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,6 @@ export { PedigreeView } from './PedigreeView.js';
|
|
|
2
2
|
export { PedigreeExplorer } from './PedigreeExplorer.js';
|
|
3
3
|
export * from './headless.js';
|
|
4
4
|
export { ThemeEditor } from './ThemeEditor.js';
|
|
5
|
+
export { AhnentafelList, ahnentafelRelation } from './AhnentafelList.js';
|
|
6
|
+
export { Icon, ICON_PATHS } from './icons.js';
|
|
7
|
+
export { dropNodeOrder } from './reorder.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LayoutResult } from '@captiveorigins/onepedigree-core';
|
|
2
|
+
/**
|
|
3
|
+
* Where a dragged node lands in its row (click-and-drag rearranging, shared idea with
|
|
4
|
+
* OneTaxonomy). Rows are generations: same y in vertical charts, same x in horizontal
|
|
5
|
+
* ones. Returns the new `nodeOrder` for `layoutSync` (the row's order replaces any
|
|
6
|
+
* earlier choice for those nodes), or null when nothing changes. The layout applies
|
|
7
|
+
* it only where order is a choice (REORDERABLE_VIEWS), e.g. among siblings.
|
|
8
|
+
*/
|
|
9
|
+
export declare function dropNodeOrder(layout: LayoutResult, occurrenceId: string, delta: {
|
|
10
|
+
dx: number;
|
|
11
|
+
dy: number;
|
|
12
|
+
}, current?: readonly string[],
|
|
13
|
+
/** Nodes only move among others of the same group (e.g. the same clutch), so grouped frames stay truthful. */
|
|
14
|
+
groupOf?: (occurrenceId: string) => string | null | undefined): string[] | null;
|
package/dist/reorder.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a dragged node lands in its row (click-and-drag rearranging, shared idea with
|
|
3
|
+
* OneTaxonomy). Rows are generations: same y in vertical charts, same x in horizontal
|
|
4
|
+
* ones. Returns the new `nodeOrder` for `layoutSync` (the row's order replaces any
|
|
5
|
+
* earlier choice for those nodes), or null when nothing changes. The layout applies
|
|
6
|
+
* it only where order is a choice (REORDERABLE_VIEWS), e.g. among siblings.
|
|
7
|
+
*/
|
|
8
|
+
export function dropNodeOrder(layout, occurrenceId, delta, current = [],
|
|
9
|
+
/** Nodes only move among others of the same group (e.g. the same clutch), so grouped frames stay truthful. */
|
|
10
|
+
groupOf) {
|
|
11
|
+
const vertical = layout.orientation === 'vertical';
|
|
12
|
+
const self = layout.nodes.find((n) => n.occurrenceId === occurrenceId);
|
|
13
|
+
if (!self)
|
|
14
|
+
return null;
|
|
15
|
+
const rowOf = (n) => (vertical ? n.y : n.x);
|
|
16
|
+
const along = (n) => (vertical ? n.x + n.width / 2 : n.y + n.height / 2);
|
|
17
|
+
const group = groupOf?.(occurrenceId);
|
|
18
|
+
const row = layout.nodes.filter((n) => Math.abs(rowOf(n) - rowOf(self)) < 0.5 && (!groupOf || groupOf(n.occurrenceId) === group));
|
|
19
|
+
if (row.length < 2)
|
|
20
|
+
return null;
|
|
21
|
+
const before = [...row].sort((a, b) => along(a) - along(b)).map((n) => n.occurrenceId);
|
|
22
|
+
const moved = along(self) + (vertical ? delta.dx : delta.dy);
|
|
23
|
+
const after = [...row].sort((a, b) => (a === self ? moved : along(a)) - (b === self ? moved : along(b))).map((n) => n.occurrenceId);
|
|
24
|
+
if (after.every((id, i) => id === before[i]))
|
|
25
|
+
return null;
|
|
26
|
+
const inRow = new Set(after);
|
|
27
|
+
return [...current.filter((id) => !inRow.has(id)), ...after];
|
|
28
|
+
}
|
package/dist/styles.css
CHANGED
|
@@ -73,6 +73,11 @@
|
|
|
73
73
|
.op-individual[data-generation='1'] .op-fan-segment,
|
|
74
74
|
.op-individual[data-generation='3'] .op-fan-segment,
|
|
75
75
|
.op-individual[data-generation='5'] .op-fan-segment { fill: var(--op-fan-fill-alt); }
|
|
76
|
+
.op-fan-label { pointer-events: none; }
|
|
77
|
+
.op-life-bar[data-open] { stroke-dasharray: 4 3; }
|
|
78
|
+
.op-axis-line { stroke: var(--op-connector); stroke-width: 1px; }
|
|
79
|
+
.op-axis-tick { stroke: var(--op-connector); stroke-opacity: 0.18; stroke-width: 1px; }
|
|
80
|
+
.op-view-timeline .op-connector { stroke-opacity: 0.55; }
|
|
76
81
|
.op-cohort-box { fill: none; stroke: var(--op-cohort-border); stroke-dasharray: 4 4; }
|
|
77
82
|
.op-cohort-label { fill: var(--op-cohort-label); font-size: 11px; }
|
|
78
83
|
|
|
@@ -81,10 +86,18 @@
|
|
|
81
86
|
.op-explorer > svg:active { cursor: grabbing; }
|
|
82
87
|
.op-toolbar { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; z-index: 1; }
|
|
83
88
|
.op-toolbar button {
|
|
89
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
84
90
|
min-width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--op-node-border, #6b7a90);
|
|
85
91
|
background: var(--op-node-background, #fff); color: inherit; font: inherit; cursor: pointer;
|
|
86
92
|
}
|
|
87
|
-
|
|
93
|
+
/* Brand family (brand/README.md): shared line icons, Captive Origins orange focus ring. */
|
|
94
|
+
.op-icon { flex: none; }
|
|
95
|
+
.op-explorer[data-reorderable] .op-node { cursor: grab; }
|
|
96
|
+
.op-node[data-dragging] { cursor: grabbing; opacity: 0.85; filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.18)); }
|
|
97
|
+
.op-toolbar button:focus-visible, .op-quick-view-actions :is(a, button):focus-visible { outline: 2px solid #cf8137; outline-offset: 2px; }
|
|
98
|
+
@media (prefers-color-scheme: dark) {
|
|
99
|
+
.op-toolbar button:focus-visible, .op-quick-view-actions :is(a, button):focus-visible { outline-color: #e7a066; }
|
|
100
|
+
}
|
|
88
101
|
.op-quick-view {
|
|
89
102
|
position: absolute; left: 8px; bottom: 8px; max-width: 18rem; padding: 12px 14px; border-radius: 10px;
|
|
90
103
|
background: var(--op-node-background, #fff); border: 1px solid var(--op-node-border, #6b7a90);
|
|
@@ -93,11 +106,20 @@
|
|
|
93
106
|
.op-quick-view p { margin: 0 0 4px; }
|
|
94
107
|
.op-quick-view-title { font-weight: 600; }
|
|
95
108
|
.op-quick-view-actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
109
|
+
.op-quick-view-actions :is(a, button) { display: inline-flex; align-items: center; gap: 4px; }
|
|
96
110
|
.op-theme-panel {
|
|
97
111
|
position: absolute; top: 48px; right: 8px; z-index: 2; max-height: calc(100% - 56px); overflow: auto; width: 20rem;
|
|
98
112
|
padding: 12px; border-radius: 10px; background: var(--op-node-background, #fff); color: var(--op-label, #1d2330);
|
|
99
113
|
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
114
|
}
|
|
115
|
+
.op-ahnentafel { padding: 56px 20px 20px; overflow: auto; height: 100%; box-sizing: border-box; color: var(--op-label, #1d2330); font: 14px/1.5 var(--op-font, system-ui, sans-serif); }
|
|
116
|
+
.op-ahnentafel h3 { margin: 16px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--op-sublabel, #5b6577); }
|
|
117
|
+
.op-ahnentafel ol { list-style: none; margin: 0; padding: 0; columns: 18rem; column-gap: 24px; }
|
|
118
|
+
.op-ahnentafel li { break-inside: avoid; padding: 2px 0; }
|
|
119
|
+
.op-ahnentafel li[data-selected] { font-weight: 600; }
|
|
120
|
+
.op-ahnentafel button { all: unset; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
121
|
+
.op-ahnentafel-number { display: inline-block; min-width: 2.5em; text-align: end; color: var(--op-sublabel, #5b6577); font-variant-numeric: tabular-nums; }
|
|
122
|
+
.op-ahnentafel-years, .op-ahnentafel-relation, .op-ahnentafel-note { color: var(--op-sublabel, #5b6577); font-size: 13px; }
|
|
101
123
|
.op-theme-editor-title { font-weight: 600; margin: 0 0 8px; }
|
|
102
124
|
.op-theme-row { display: grid; grid-template-columns: 1fr 2.25rem 5.5rem; align-items: center; gap: 6px; margin: 4px 0; }
|
|
103
125
|
.op-theme-row select { grid-column: 2 / 4; }
|
|
@@ -111,6 +133,9 @@
|
|
|
111
133
|
.op-root * { transition: none !important; animation: none !important; }
|
|
112
134
|
}
|
|
113
135
|
@media print {
|
|
136
|
+
html[data-op-printing] body * { visibility: hidden; }
|
|
137
|
+
html[data-op-printing] [data-op-printing], html[data-op-printing] [data-op-printing] * { visibility: visible; }
|
|
138
|
+
html[data-op-printing] [data-op-printing] { position: fixed; inset: 0; height: 100% !important; border-radius: 0; }
|
|
114
139
|
.op-toolbar, .op-quick-view, .op-theme-panel { display: none; }
|
|
115
140
|
.op-explorer { height: auto !important; overflow: visible; }
|
|
116
141
|
.op-root { --op-node-background: #fff; --op-label: #000; --op-connector: #555; }
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@captiveorigins/onepedigree-react",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"description": "OnePedigree for React: server-renderable SVG pedigree views (all 13), interactive explorer, headless hooks and CSS-variable styling.",
|
|
3
|
+
"version": "0.1.0-alpha.9",
|
|
4
|
+
"description": "OnePedigree for React: server-renderable SVG pedigree views (all 13), an interactive explorer, headless hooks and CSS-variable styling.",
|
|
5
|
+
"homepage": "https://github.com/mgilardi/onepedigree#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mgilardi/onepedigree/issues"
|
|
8
|
+
},
|
|
5
9
|
"keywords": [
|
|
6
10
|
"pedigree",
|
|
7
11
|
"genealogy",
|
|
@@ -42,7 +46,7 @@
|
|
|
42
46
|
"node": ">=22"
|
|
43
47
|
},
|
|
44
48
|
"dependencies": {
|
|
45
|
-
"@captiveorigins/onepedigree-core": "^0.1.0-alpha.
|
|
49
|
+
"@captiveorigins/onepedigree-core": "^0.1.0-alpha.9"
|
|
46
50
|
},
|
|
47
51
|
"peerDependencies": {
|
|
48
52
|
"react": ">=18.2"
|