@captiveorigins/onepedigree-react 0.1.0-alpha.5 → 0.1.0-alpha.8

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.
@@ -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
+ }
@@ -20,6 +20,13 @@ 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
+ };
23
30
  /** Accessible labels for the controls. */
24
31
  labels?: Partial<Record<'zoomIn' | 'zoomOut' | 'fit' | 'download' | 'print' | 'close' | 'open' | 'controls' | 'colors', string>>;
25
32
  };
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { AhnentafelList } from './AhnentafelList.js';
4
5
  import { DEFAULT_TERMS, describeStructure, labelOf } from './describe.js';
5
6
  import { nearestNode, useOccurrenceHighlighting, usePedigreeSelection } from './hooks.js';
6
7
  import { PedigreeView } from './PedigreeView.js';
@@ -17,6 +18,22 @@ const LABELS = {
17
18
  controls: 'Pedigree controls',
18
19
  colors: 'Colors',
19
20
  };
21
+ /** Properties copied into downloaded SVGs (they have no stylesheet). */
22
+ const INLINE = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-opacity', 'opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'visibility', 'display'];
23
+ /** Prints only this chart: everything else on the page is hidden while printing. */
24
+ function printChart(el) {
25
+ if (!el)
26
+ return window.print();
27
+ el.setAttribute('data-op-printing', '');
28
+ document.documentElement.setAttribute('data-op-printing', '');
29
+ const done = () => {
30
+ el.removeAttribute('data-op-printing');
31
+ document.documentElement.removeAttribute('data-op-printing');
32
+ window.removeEventListener('afterprint', done);
33
+ };
34
+ window.addEventListener('afterprint', done);
35
+ window.print();
36
+ }
20
37
  const KEY_DIRECTION = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
21
38
  /**
22
39
  * Interactive pedigree: pan (drag, Shift+arrows), zoom (wheel, +/-, buttons),
@@ -133,6 +150,22 @@ export function PedigreeExplorer(props) {
133
150
  return;
134
151
  const clone = svg.cloneNode(true);
135
152
  clone.setAttribute('viewBox', `${full.x} ${full.y} ${full.width} ${full.height}`);
153
+ clone.setAttribute('width', String(full.width));
154
+ clone.setAttribute('height', String(full.height));
155
+ // A downloaded file has no stylesheet: copy each element's computed colors and fonts into it.
156
+ const source = [svg, ...svg.querySelectorAll('*')];
157
+ const target = [clone, ...clone.querySelectorAll('*')];
158
+ source.forEach((el, i) => {
159
+ const cs = getComputedStyle(el);
160
+ target[i]?.setAttribute('style', INLINE.map((prop) => `${prop}:${cs.getPropertyValue(prop)}`).join(';'));
161
+ });
162
+ const bg = getComputedStyle(root.current).backgroundColor;
163
+ if (bg && bg !== 'rgba(0, 0, 0, 0)') {
164
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
165
+ for (const [k, v] of Object.entries({ x: full.x, y: full.y, width: full.width, height: full.height, fill: bg }))
166
+ rect.setAttribute(k, String(v));
167
+ clone.insertBefore(rect, clone.firstChild);
168
+ }
136
169
  const blob = new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml' });
137
170
  const a = document.createElement('a');
138
171
  a.href = URL.createObjectURL(blob);
@@ -144,5 +177,5 @@ export function PedigreeExplorer(props) {
144
177
  const individual = occurrence ? projection.individuals[occurrence.individualId] : undefined;
145
178
  const href = individual ? (props.getIndividualHref?.(individual) ?? null) : null;
146
179
  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 })] })] })) }))] }));
180
+ 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: () => (props.onPrint ? props.onPrint() : printChart(root.current)), "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 }) })), 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 && _jsx("a", { href: href, children: labels.open }), _jsx("button", { type: "button", onClick: close, children: labels.close })] })] })) }))] }));
148
181
  }
@@ -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
- 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));
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("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 }) }));
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/index.d.ts CHANGED
@@ -2,3 +2,4 @@ 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';
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ 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';
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
 
@@ -98,6 +103,14 @@
98
103
  padding: 12px; border-radius: 10px; background: var(--op-node-background, #fff); color: var(--op-label, #1d2330);
99
104
  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
105
  }
106
+ .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); }
107
+ .op-ahnentafel h3 { margin: 16px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--op-sublabel, #5b6577); }
108
+ .op-ahnentafel ol { list-style: none; margin: 0; padding: 0; columns: 18rem; column-gap: 24px; }
109
+ .op-ahnentafel li { break-inside: avoid; padding: 2px 0; }
110
+ .op-ahnentafel li[data-selected] { font-weight: 600; }
111
+ .op-ahnentafel button { all: unset; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
112
+ .op-ahnentafel-number { display: inline-block; min-width: 2.5em; text-align: end; color: var(--op-sublabel, #5b6577); font-variant-numeric: tabular-nums; }
113
+ .op-ahnentafel-years, .op-ahnentafel-relation, .op-ahnentafel-note { color: var(--op-sublabel, #5b6577); font-size: 13px; }
101
114
  .op-theme-editor-title { font-weight: 600; margin: 0 0 8px; }
102
115
  .op-theme-row { display: grid; grid-template-columns: 1fr 2.25rem 5.5rem; align-items: center; gap: 6px; margin: 4px 0; }
103
116
  .op-theme-row select { grid-column: 2 / 4; }
@@ -111,6 +124,9 @@
111
124
  .op-root * { transition: none !important; animation: none !important; }
112
125
  }
113
126
  @media print {
127
+ html[data-op-printing] body * { visibility: hidden; }
128
+ html[data-op-printing] [data-op-printing], html[data-op-printing] [data-op-printing] * { visibility: visible; }
129
+ html[data-op-printing] [data-op-printing] { position: fixed; inset: 0; height: 100% !important; border-radius: 0; }
114
130
  .op-toolbar, .op-quick-view, .op-theme-panel { display: none; }
115
131
  .op-explorer { height: auto !important; overflow: visible; }
116
132
  .op-root { --op-node-background: #fff; --op-label: #000; --op-connector: #555; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@captiveorigins/onepedigree-react",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.8",
4
4
  "description": "OnePedigree for React: server-renderable SVG pedigree views (all 13), interactive explorer, headless hooks and CSS-variable styling.",
5
5
  "keywords": [
6
6
  "pedigree",
@@ -42,7 +42,7 @@
42
42
  "node": ">=22"
43
43
  },
44
44
  "dependencies": {
45
- "@captiveorigins/onepedigree-core": "^0.1.0-alpha.5"
45
+ "@captiveorigins/onepedigree-core": "^0.1.0-alpha.8"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "react": ">=18.2"