@captiveorigins/onetaxonomy-react 0.0.0-stage → 0.1.0-alpha.2

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,35 @@
1
+ import { type DatasetIndex } from '@captiveorigins/onetaxonomy-core';
2
+ import { type Camera, type LessonDeck } from '@captiveorigins/onetaxonomy-education';
3
+ import type { TreeLayout } from '@captiveorigins/onetaxonomy-layout';
4
+ import { type ViewBox } from './TreeView.js';
5
+ export type LessonMode =
6
+ /** Teacher presents: the drawing follows the lesson, step by step. */
7
+ 'present'
8
+ /** Students explore from the current step with the full explorer (changes stay local). */
9
+ | 'explore'
10
+ /** Authoring: build scenes from the explorer, annotate nodes. */
11
+ | 'studio';
12
+ export type LessonLabels = Record<'previous' | 'next' | 'scene' | 'step' | 'present' | 'explore' | 'studio' | 'addScene' | 'sceneTitle' | 'annotate' | 'annotation' | 'remove' | 'notes' | 'selectToAnnotate' | 'modes', string>;
13
+ /** The viewBox for a camera: centered on its node (or the whole tree), zoom 1 = fit. */
14
+ export declare function cameraBox(layout: TreeLayout, camera: Camera | undefined, full?: ViewBox): ViewBox;
15
+ export type LessonPlayerProps = {
16
+ dataset: DatasetIndex;
17
+ deck: LessonDeck;
18
+ /** Starting mode (default present). Studio needs `onChange`. */
19
+ mode?: LessonMode;
20
+ /** Modes offered in the switcher (default: present and explore, plus studio when `onChange` is given). */
21
+ modes?: LessonMode[];
22
+ /** Called with the edited lesson (studio). */
23
+ onChange?: (deck: LessonDeck) => void;
24
+ /** Show teacher notes (default false). */
25
+ showNotes?: boolean;
26
+ height?: string;
27
+ labels?: Partial<LessonLabels>;
28
+ };
29
+ /**
30
+ * Plays a lesson deck. Present mode draws exactly what the lesson says at each step
31
+ * (camera, reveals, annotations as numbered markers with their text beside the
32
+ * drawing); explore mode opens the full explorer at that point; studio mode lets an
33
+ * author add scenes from the explorer's current view and annotate taxa.
34
+ */
35
+ export declare function LessonPlayer({ dataset, deck, mode: initialMode, modes, onChange, showNotes, height, labels: custom }: LessonPlayerProps): import("react").JSX.Element;
package/dist/Lesson.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { projectView } from '@captiveorigins/onetaxonomy-core';
4
+ import { sceneAt, sceneFromView } from '@captiveorigins/onetaxonomy-education';
5
+ import { useMemo, useState } from 'react';
6
+ import { Explorer, layoutFor } from './Explorer.js';
7
+ import { Icon } from './icons.js';
8
+ import { treeViewBox, TreeView } from './TreeView.js';
9
+ const LABELS = {
10
+ previous: 'Previous',
11
+ next: 'Next',
12
+ scene: 'Scene',
13
+ step: 'Step',
14
+ present: 'Present',
15
+ explore: 'Explore',
16
+ studio: 'Studio',
17
+ addScene: 'Add scene from this view',
18
+ sceneTitle: 'Scene title',
19
+ annotate: 'Annotate the selected taxon',
20
+ annotation: 'Note',
21
+ remove: 'Remove',
22
+ notes: 'Teacher notes',
23
+ selectToAnnotate: 'Choose a taxon in the drawing to annotate it.',
24
+ modes: 'Lesson mode',
25
+ };
26
+ /** The viewBox for a camera: centered on its node (or the whole tree), zoom 1 = fit. */
27
+ export function cameraBox(layout, camera, full = treeViewBox(layout)) {
28
+ const zoom = camera?.zoom && camera.zoom > 0 ? camera.zoom : 1;
29
+ const node = camera?.focus ? layout.nodes.find((n) => n.id === camera.focus) : undefined;
30
+ const cx = node ? node.x : full.x + full.width / 2;
31
+ const cy = node ? node.y : full.y + full.height / 2;
32
+ const width = full.width / zoom;
33
+ const height = full.height / zoom;
34
+ return { x: cx - width / 2, y: cy - height / 2, width, height };
35
+ }
36
+ /**
37
+ * Plays a lesson deck. Present mode draws exactly what the lesson says at each step
38
+ * (camera, reveals, annotations as numbered markers with their text beside the
39
+ * drawing); explore mode opens the full explorer at that point; studio mode lets an
40
+ * author add scenes from the explorer's current view and annotate taxa.
41
+ */
42
+ export function LessonPlayer({ dataset, deck, mode: initialMode = 'present', modes, onChange, showNotes = false, height = '30rem', labels: custom }) {
43
+ const labels = { ...LABELS, ...custom };
44
+ const offered = modes ?? ['present', 'explore', ...(onChange ? ['studio'] : [])];
45
+ const [mode, setMode] = useState(initialMode);
46
+ const [sceneIndex, setSceneIndex] = useState(0);
47
+ const [step, setStep] = useState(0);
48
+ const scene = deck.scenes[Math.min(sceneIndex, deck.scenes.length - 1)];
49
+ const frame = useMemo(() => (scene ? sceneAt(scene, step, dataset) : null), [scene, step, dataset]);
50
+ const go = (delta) => {
51
+ if (!scene)
52
+ return;
53
+ const next = step + delta;
54
+ if (next >= 0 && next <= scene.steps.length)
55
+ return setStep(next);
56
+ const s = sceneIndex + delta;
57
+ if (s < 0 || s >= deck.scenes.length)
58
+ return;
59
+ setSceneIndex(s);
60
+ setStep(delta > 0 ? 0 : deck.scenes[s].steps.length);
61
+ };
62
+ return (_jsxs("div", { className: "ot-lesson", "data-mode": mode, children: [_jsxs("div", { className: "ot-toolbar", role: "toolbar", "aria-label": deck.title, children: [_jsx("div", { className: "ot-segmented", role: "radiogroup", "aria-label": labels.modes, children: offered.map((m) => (_jsx("button", { type: "button", role: "radio", "aria-checked": mode === m, onClick: () => setMode(m), children: labels[m] }, m))) }), _jsx("span", { className: "ot-toolbar-gap", "aria-hidden": "true" }), _jsxs("button", { type: "button", onClick: () => go(-1), disabled: sceneIndex === 0 && step === 0, children: [_jsx(Icon, { name: "previous" }), labels.previous] }), _jsxs("span", { className: "ot-lesson-position", "aria-live": "polite", children: [labels.scene, " ", deck.scenes.length ? sceneIndex + 1 : 0, "/", deck.scenes.length, scene && scene.steps.length > 0 ? ` · ${labels.step} ${step}/${scene.steps.length}` : ''] }), _jsxs("button", { type: "button", onClick: () => go(1), disabled: !scene || (sceneIndex === deck.scenes.length - 1 && step === scene.steps.length), children: [labels.next, _jsx(Icon, { name: "next" })] })] }), scene && frame && (_jsxs(_Fragment, { children: [_jsx("h2", { className: "ot-lesson-title", children: scene.title }), frame.caption && _jsx("p", { className: "ot-lesson-caption", children: frame.caption }), mode === 'present' && _jsx(Present, { dataset: dataset, view: frame.view, camera: frame.camera, annotations: frame.annotations, title: scene.title, height: height }), mode === 'explore' && _jsx(Explorer, { dataset: dataset, initialView: frame.view, title: scene.title, height: height }, `${scene.id}:${step}`), mode === 'studio' && onChange && _jsx(Studio, { dataset: dataset, deck: deck, sceneIndex: sceneIndex, startView: frame.view, onChange: onChange, labels: labels, height: height, onAdded: (i) => { setSceneIndex(i); setStep(0); } }), showNotes && scene.notes && (_jsxs("aside", { className: "ot-lesson-notes", children: [_jsxs("strong", { children: [labels.notes, ":"] }), " ", scene.notes] }))] }))] }));
63
+ }
64
+ function Present({ dataset, view, camera, annotations, title, height }) {
65
+ const projection = useMemo(() => projectView(dataset, view), [dataset, view]);
66
+ const layout = useMemo(() => layoutFor(projection, view.layout.kind), [projection, view.layout.kind]);
67
+ const box = useMemo(() => cameraBox(layout, camera), [layout, camera]);
68
+ const badges = new Map();
69
+ annotations.forEach((a, i) => {
70
+ const target = a.target.kind === 'node' ? a.target.node : a.target.kind === 'group' ? a.target.members[0] : undefined;
71
+ if (target && !badges.has(target))
72
+ badges.set(target, String(i + 1));
73
+ });
74
+ return (_jsxs("div", { className: "ot-panes", children: [_jsx("div", { className: "ot-canvas ot-present", style: { height }, children: _jsx(TreeView, { projection: projection, layout: layout, highlights: view.highlights, title: title, viewBox: box, badges: badges }) }), annotations.length > 0 && (_jsx("ol", { className: "ot-annotations", children: annotations.map((a) => (_jsx("li", { "data-tone": a.tone ?? 'note', children: a.text }, a.id))) }))] }));
75
+ }
76
+ function Studio({ dataset, deck, sceneIndex, startView, onChange, labels, height, onAdded }) {
77
+ const [view, setView] = useState(startView);
78
+ const [title, setTitle] = useState('');
79
+ const [note, setNote] = useState('');
80
+ const scene = deck.scenes[sceneIndex];
81
+ const selected = view.selected[0];
82
+ const uid = (p) => `${p}-${Date.now().toString(36)}`;
83
+ const update = (fn) => {
84
+ const next = JSON.parse(JSON.stringify(deck));
85
+ fn(next);
86
+ onChange(next);
87
+ };
88
+ return (_jsxs("div", { className: "ot-studio", children: [_jsx(Explorer, { dataset: dataset, initialView: startView, title: scene?.title ?? deck.title, height: height, onViewChange: setView }, `${scene?.id ?? 'new'}`), _jsxs("div", { className: "ot-studio-tools", children: [_jsxs("form", { className: "ot-inspector-actions", onSubmit: (e) => {
89
+ e.preventDefault();
90
+ update((d) => {
91
+ d.scenes.splice(sceneIndex + 1, 0, sceneFromView({ ...view, selected: [] }, { id: uid('scene'), title: title || `${labels.scene} ${d.scenes.length + 1}` }));
92
+ });
93
+ setTitle('');
94
+ onAdded(sceneIndex + 1);
95
+ }, children: [_jsxs("label", { className: "ot-select", children: [_jsx("span", { children: labels.sceneTitle }), _jsx("input", { value: title, onChange: (e) => setTitle(e.target.value) })] }), _jsxs("button", { type: "submit", children: [_jsx(Icon, { name: "plus" }), labels.addScene] })] }), scene && (_jsxs("form", { className: "ot-inspector-actions", onSubmit: (e) => {
96
+ e.preventDefault();
97
+ if (!selected || !note.trim())
98
+ return;
99
+ update((d) => {
100
+ d.scenes[sceneIndex].annotations.push({ id: uid('note'), text: note.trim(), target: { kind: 'node', node: selected } });
101
+ });
102
+ setNote('');
103
+ }, children: [_jsxs("label", { className: "ot-select", children: [_jsx("span", { children: labels.annotation }), _jsx("input", { value: note, onChange: (e) => setNote(e.target.value), disabled: !selected, placeholder: selected ? '' : labels.selectToAnnotate })] }), _jsxs("button", { type: "submit", disabled: !selected || !note.trim(), children: [_jsx(Icon, { name: "info" }), labels.annotate] })] })), scene && scene.annotations.length > 0 && (_jsx("ol", { className: "ot-annotations", children: scene.annotations.map((a) => (_jsxs("li", { children: [a.text, ' ', _jsx("button", { type: "button", className: "ot-remove", "aria-label": `${labels.remove}: ${a.text}`, onClick: () => update((d) => void (d.scenes[sceneIndex].annotations = d.scenes[sceneIndex].annotations.filter((x) => x.id !== a.id))), children: _jsx(Icon, { name: "close", size: 12 }) })] }, a.id))) }))] })] }));
104
+ }
@@ -0,0 +1,27 @@
1
+ import type { Id, Projection, ProjectedNode } from '@captiveorigins/onetaxonomy-core';
2
+ import { type ReactNode } from 'react';
3
+ export type TreeOutlineProps = {
4
+ projection: Projection;
5
+ /** Accessible name of the tree. */
6
+ label: string;
7
+ /** Expand or collapse a node (Enter on a branch, Left/Right arrows). */
8
+ onToggle?: (id: Id) => void;
9
+ /** Choose a node (Space, click). `additive` (Shift/Ctrl/⌘) adds or removes it from a multiple selection. */
10
+ onSelect?: (id: Id, options?: {
11
+ additive: boolean;
12
+ }) => void;
13
+ /** Move a node among its siblings (Alt+Up/Down): a keyboard alternative to dragging. */
14
+ onMove?: (id: Id, direction: -1 | 1) => void;
15
+ /** Extra text after a node's name, e.g. its rank. */
16
+ renderMeta?: (node: ProjectedNode) => ReactNode;
17
+ hiddenLabel?: (count: number) => string;
18
+ /** Shown for nodes without a name (e.g. unlabelled internal nodes of a tree). */
19
+ unnamedLabel?: string;
20
+ };
21
+ /**
22
+ * The same tree as an accessible outline (WAI-ARIA tree pattern): one tab stop,
23
+ * arrows to move, Right/Left to open and close, Home/End, type-ahead, Space to
24
+ * select, Alt+Up/Down to reorder. Screen-reader users get the whole structure;
25
+ * everyone gets a keyboard path to every drawing interaction.
26
+ */
27
+ export declare function TreeOutline({ projection, label, onToggle, onSelect, onMove, renderMeta, hiddenLabel, unnamedLabel }: TreeOutlineProps): import("react").JSX.Element;
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ const DEFAULT_HIDDEN = (n) => `${n} not shown`;
5
+ /**
6
+ * The same tree as an accessible outline (WAI-ARIA tree pattern): one tab stop,
7
+ * arrows to move, Right/Left to open and close, Home/End, type-ahead, Space to
8
+ * select, Alt+Up/Down to reorder. Screen-reader users get the whole structure;
9
+ * everyone gets a keyboard path to every drawing interaction.
10
+ */
11
+ export function TreeOutline({ projection, label, onToggle, onSelect, onMove, renderMeta, hiddenLabel = DEFAULT_HIDDEN, unnamedLabel = 'Unnamed node' }) {
12
+ const byId = useMemo(() => new Map(projection.nodes.map((n) => [n.id, n])), [projection]);
13
+ const order = useMemo(() => projection.nodes.map((n) => n.id), [projection]); // preorder = visual order
14
+ const [active, setActive] = useState(projection.roots[0] ?? null);
15
+ const refs = useRef(new Map());
16
+ const typed = useRef({ text: '', at: 0 });
17
+ const current = active && byId.has(active) ? active : (projection.roots[0] ?? null);
18
+ const [focusPending, setFocusPending] = useState(false);
19
+ useEffect(() => {
20
+ if (focusPending && current) {
21
+ refs.current.get(current)?.focus();
22
+ setFocusPending(false);
23
+ }
24
+ }, [focusPending, current]);
25
+ const go = (id) => {
26
+ if (!id)
27
+ return;
28
+ setActive(id);
29
+ setFocusPending(true);
30
+ };
31
+ const onKeyDown = (e, n) => {
32
+ e.stopPropagation();
33
+ const i = order.indexOf(n.id);
34
+ const branch = !n.isTip;
35
+ const open = branch && !n.collapsed;
36
+ const siblings = n.parent ? byId.get(n.parent).children : projection.roots;
37
+ const handled = () => e.preventDefault();
38
+ if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && onMove && n.parent) {
39
+ handled();
40
+ onMove(n.id, e.key === 'ArrowUp' ? -1 : 1);
41
+ setFocusPending(true);
42
+ return;
43
+ }
44
+ switch (e.key) {
45
+ case 'ArrowDown':
46
+ handled();
47
+ return go(order[i + 1]);
48
+ case 'ArrowUp':
49
+ handled();
50
+ return go(order[i - 1]);
51
+ case 'Home':
52
+ handled();
53
+ return go(order[0]);
54
+ case 'End':
55
+ handled();
56
+ return go(order.at(-1));
57
+ case 'ArrowRight':
58
+ handled();
59
+ if (branch && !open)
60
+ return onToggle?.(n.id);
61
+ return go(n.children[0]);
62
+ case 'ArrowLeft':
63
+ handled();
64
+ if (open && onToggle)
65
+ return onToggle(n.id);
66
+ return go(n.parent);
67
+ case 'Enter':
68
+ handled();
69
+ if (branch)
70
+ onToggle?.(n.id);
71
+ else
72
+ onSelect?.(n.id);
73
+ return;
74
+ case ' ':
75
+ handled();
76
+ return onSelect?.(n.id, { additive: e.shiftKey || e.ctrlKey || e.metaKey });
77
+ case '*':
78
+ handled();
79
+ for (const s of siblings)
80
+ if (byId.get(s).collapsed)
81
+ onToggle?.(s);
82
+ return;
83
+ default:
84
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
85
+ const now = Date.now();
86
+ typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : '') + e.key.toLowerCase(), at: now };
87
+ const from = [...order.slice(i + 1), ...order.slice(0, i + 1)];
88
+ go(from.find((id) => byId.get(id).label.toLowerCase().startsWith(typed.current.text)));
89
+ }
90
+ }
91
+ };
92
+ const item = (id) => {
93
+ const n = byId.get(id);
94
+ const branch = !n.isTip;
95
+ const siblings = n.parent ? byId.get(n.parent).children : projection.roots;
96
+ return (_jsxs("li", { ref: (el) => {
97
+ if (el)
98
+ refs.current.set(id, el);
99
+ else
100
+ refs.current.delete(id);
101
+ }, role: "treeitem", className: "ot-outline-item", "aria-level": n.depth + 1, "aria-setsize": siblings.length, "aria-posinset": siblings.indexOf(id) + 1, "aria-expanded": branch ? !n.collapsed : undefined, "aria-selected": n.selected, tabIndex: id === current ? 0 : -1, onKeyDown: (e) => onKeyDown(e, n), onFocus: (e) => {
102
+ if (e.target === e.currentTarget)
103
+ setActive(id);
104
+ }, "data-status": n.status, children: [_jsxs("span", { className: "ot-outline-row", onClick: (e) => {
105
+ setActive(id);
106
+ onSelect?.(id, { additive: e.shiftKey || e.ctrlKey || e.metaKey });
107
+ }, children: [branch ? (_jsx("span", { className: "ot-outline-twisty", "aria-hidden": "true", onClick: (e) => {
108
+ e.stopPropagation();
109
+ onToggle?.(id);
110
+ }, children: n.collapsed ? '▸' : '▾' })) : (_jsx("span", { className: "ot-outline-twisty", "aria-hidden": "true" })), _jsxs("span", { className: "ot-outline-label", "data-unnamed": n.label ? undefined : '', children: [n.label || unnamedLabel, n.status === 'extinct' ? ' †' : ''] }), renderMeta?.(n), n.hiddenDescendants > 0 && _jsxs("span", { className: "ot-outline-hidden", children: [" \u00B7 ", hiddenLabel(n.hiddenDescendants)] })] }), branch && !n.collapsed && n.children.length > 0 && _jsx("ul", { role: "group", children: n.children.map(item) })] }, id));
111
+ };
112
+ return (_jsx("ul", { className: "ot-outline", role: "tree", "aria-label": label, children: projection.roots.map(item) }));
113
+ }
@@ -0,0 +1,54 @@
1
+ import type { Highlight, Id, NodeOverlay, Projection } from '@captiveorigins/onetaxonomy-core';
2
+ import { type TreeLayout } from '@captiveorigins/onetaxonomy-layout';
3
+ import { type KeyboardEvent, type MouseEvent } from 'react';
4
+ export type TreeViewProps = {
5
+ projection: Projection;
6
+ layout: TreeLayout;
7
+ /** Highlights from the view (colors and clade/group kind). */
8
+ highlights?: Highlight[];
9
+ /** Accessible title of the drawing. */
10
+ title: string;
11
+ /** Space around the drawing, in layout units. Default 16. */
12
+ padding?: number;
13
+ /** Room for labels to the right of leaves (rectangular layouts). Default 220. */
14
+ labelWidth?: number;
15
+ /** Override the viewBox (zoom and pan). */
16
+ viewBox?: ViewBox;
17
+ /** A node being dragged: it and its drawn subtree are shown offset (layout units). */
18
+ drag?: {
19
+ ids: ReadonlySet<Id>;
20
+ dx: number;
21
+ dy: number;
22
+ } | null;
23
+ /** Overlay marks per node (`nodeOverlays` from core): support, fossil ages, observed trait states. */
24
+ overlays?: ReadonlyMap<Id, NodeOverlay>;
25
+ /** The node a dragged taxon would land on (Proposed mode). */
26
+ dropTarget?: Id | null;
27
+ /** Small numbered markers on nodes (e.g. lesson annotations), by node id. */
28
+ badges?: ReadonlyMap<Id, string>;
29
+ /** Nodes are focusable and clickable. */
30
+ interactive?: boolean;
31
+ onNodeClick?: (id: Id, event: MouseEvent<SVGGElement>) => void;
32
+ onNodeKeyDown?: (id: Id, event: KeyboardEvent<SVGGElement>) => void;
33
+ /** Word for folded descendants, e.g. "+12 hidden". */
34
+ hiddenLabel?: (count: number) => string;
35
+ className?: string;
36
+ };
37
+ export type ViewBox = {
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ height: number;
42
+ };
43
+ /** The box that shows the whole drawing with room for labels (the "fit" view). */
44
+ export declare function treeViewBox(layout: TreeLayout, { padding, labelWidth }?: {
45
+ padding?: number;
46
+ labelWidth?: number;
47
+ }): ViewBox;
48
+ /**
49
+ * The tree as SVG. Renders on the server (no effects, no measuring) and in the
50
+ * browser. Classes are stable (`ot-*`) and states are data attributes, so it can
51
+ * be styled with the bundled stylesheet or entirely your own. The legend notice
52
+ * (classification vs phylogeny) is part of the accessible description.
53
+ */
54
+ export declare function TreeView(props: TreeViewProps): import("react").JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { edgePath } from '@captiveorigins/onetaxonomy-layout';
3
+ import { useId } from 'react';
4
+ const DEFAULT_HIDDEN = (n) => `+${n} not shown`;
5
+ /** The box that shows the whole drawing with room for labels (the "fit" view). */
6
+ export function treeViewBox(layout, { padding = 16, labelWidth } = {}) {
7
+ const radialLayout = layout.kind === 'radial';
8
+ const lw = labelWidth ?? (radialLayout ? 160 : 220);
9
+ const xs = layout.nodes.map((n) => n.x);
10
+ const ys = layout.nodes.map((n) => n.y);
11
+ const minX = Math.min(0, ...xs) - padding - (radialLayout ? lw : 0);
12
+ const minY = Math.min(0, ...ys) - padding - (radialLayout ? lw / 2 : 0);
13
+ const maxX = Math.max(0, ...xs) + padding + lw;
14
+ const maxY = Math.max(0, ...ys) + padding + (layout.axis ? 36 : 0) + (radialLayout ? lw / 2 : 0);
15
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
16
+ }
17
+ /**
18
+ * The tree as SVG. Renders on the server (no effects, no measuring) and in the
19
+ * browser. Classes are stable (`ot-*`) and states are data attributes, so it can
20
+ * be styled with the bundled stylesheet or entirely your own. The legend notice
21
+ * (classification vs phylogeny) is part of the accessible description.
22
+ */
23
+ export function TreeView(props) {
24
+ const { projection, layout, highlights = [], title, padding = 16, interactive = false } = props;
25
+ const titleId = useId();
26
+ const descId = useId();
27
+ const radialLayout = layout.kind === 'radial';
28
+ const box = props.viewBox ?? treeViewBox(layout, { padding, ...(props.labelWidth !== undefined ? { labelWidth: props.labelWidth } : {}) });
29
+ const node = new Map(projection.nodes.map((n) => [n.id, n]));
30
+ const color = new Map(highlights.map((h) => [h.id, h.color]));
31
+ const hidden = props.hiddenLabel ?? DEFAULT_HIDDEN;
32
+ const moving = (id) => Boolean(props.drag?.ids.has(id));
33
+ const offset = props.drag ? `translate(${props.drag.dx} ${props.drag.dy})` : undefined;
34
+ const marks = props.overlays;
35
+ return (_jsxs("svg", { className: ['ot-root', props.className].filter(Boolean).join(' '), viewBox: `${box.x} ${box.y} ${box.width} ${box.height}`, role: "img", "aria-labelledby": titleId, "aria-describedby": descId, "data-diagram": layout.diagram, "data-layout": layout.kind, children: [_jsx("title", { id: titleId, children: title }), _jsx("desc", { id: descId, children: `${layout.notice} ${projection.counts.shown} of ${projection.counts.total} shown.` }), _jsx("g", { className: "ot-edges", children: layout.edges.map((e) => (_jsx("path", { className: "ot-edge", d: edgePath(e), "data-highlighted": node.get(e.child)?.highlights.length ? '' : undefined, "data-weak": marks?.get(e.child)?.support?.weak ? '' : undefined, "data-dragging": moving(e.child) ? '' : undefined, transform: moving(e.child) ? offset : undefined }, `${e.parent}>${e.child}`))) }), layout.axis && (_jsxs("g", { className: "ot-axis", "aria-hidden": "true", children: [_jsx("line", { className: "ot-axis-line", x1: Math.min(...layout.axis.ticks.map((t) => t.x)), x2: Math.max(...layout.axis.ticks.map((t) => t.x)), y1: layout.height + 16, y2: layout.height + 16 }), layout.axis.ticks.map((t) => (_jsxs("g", { transform: `translate(${t.x} ${layout.height + 16})`, children: [_jsx("line", { className: "ot-axis-tick", y2: 5 }), _jsx("text", { className: "ot-axis-label", y: 18, textAnchor: "middle", children: t.value })] }, t.value))), _jsx("text", { className: "ot-axis-unit", x: layout.width, y: layout.height + 50, textAnchor: "end", children: layout.axis.direction === 'before-present' ? `${layout.axis.unit} before present` : layout.axis.unit })] })), _jsx("g", { className: "ot-nodes", children: layout.nodes.map((ln) => {
36
+ const n = node.get(ln.id);
37
+ if (!n)
38
+ return null;
39
+ const hl = n.highlights.map((h) => color.get(h)).find(Boolean);
40
+ const angle = ln.angle ?? 0;
41
+ const flip = radialLayout && Math.cos(angle) < 0;
42
+ const labelTransform = radialLayout ? `rotate(${(-angle * 180) / Math.PI + (flip ? 180 : 0)})` : undefined;
43
+ const mark = marks?.get(ln.id);
44
+ const fossil = mark?.fossilAge ? ` ${mark.fossilAge.value}${mark.fossilAge.unit && mark.fossilAge.unit !== 'unspecified' ? ` ${mark.fossilAge.unit}` : ''}` : '';
45
+ const name = `${n.label}${n.status === 'extinct' ? ` †${fossil}` : ''}`;
46
+ const text = n.collapsed && n.hiddenDescendants ? `${name} (${hidden(n.hiddenDescendants)})` : name;
47
+ return (_jsxs("g", { className: "ot-node", transform: `translate(${ln.x + (moving(ln.id) ? props.drag.dx : 0)} ${ln.y + (moving(ln.id) ? props.drag.dy : 0)})`, "data-dragging": moving(ln.id) ? '' : undefined, "data-id": ln.id, "data-leaf": ln.leaf ? '' : undefined, "data-collapsed": n.collapsed ? '' : undefined, "data-selected": n.selected ? '' : undefined, "data-moved": n.moved ? '' : undefined, "data-drop-target": props.dropTarget === ln.id ? '' : undefined, "data-status": n.status, "data-rank": n.rank, "data-trait": mark?.trait?.state, ...(interactive
48
+ ? {
49
+ tabIndex: -1,
50
+ role: 'button',
51
+ 'aria-label': text,
52
+ onClick: props.onNodeClick ? (e) => props.onNodeClick(ln.id, e) : undefined,
53
+ onKeyDown: props.onNodeKeyDown ? (e) => props.onNodeKeyDown(ln.id, e) : undefined,
54
+ }
55
+ : {}), children: [hl && _jsx("circle", { className: "ot-node-halo", r: 7, style: { fill: hl } }), mark?.trait && _jsx("circle", { className: "ot-trait", r: 5.5, style: 'color' in mark.trait && mark.trait.color ? { fill: mark.trait.color } : undefined }), props.badges?.has(ln.id) && (_jsxs("g", { className: "ot-badge", transform: "translate(-12 -12)", "aria-hidden": "true", children: [_jsx("circle", { r: 7 }), _jsx("text", { y: 3.5, textAnchor: "middle", children: props.badges.get(ln.id) })] })), mark?.support && (_jsx("text", { className: "ot-support", x: -5, y: -5, textAnchor: "end", "data-weak": mark.support.weak ? '' : undefined, children: mark.support.value })), n.collapsed ? _jsx("path", { className: "ot-node-collapsed", d: "M0 0 L12 -6 L12 6 Z" }) : _jsx("circle", { className: "ot-node-dot", r: ln.leaf ? 3 : 2.5 }), (ln.leaf || !radialLayout) && text && (_jsx("text", { className: "ot-label", x: ln.leaf ? (flip ? -8 : 8) : 4, y: ln.leaf ? 4 : -6, textAnchor: ln.leaf && flip ? 'end' : 'start', transform: ln.leaf ? labelTransform : undefined, "data-italic": /^(genus|species|subspecies|variety|form)$/i.test(n.rank ?? '') ? '' : undefined, children: text }))] }, ln.id));
56
+ }) })] }));
57
+ }
package/dist/drag.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { Id, Projection } from '@captiveorigins/onetaxonomy-core';
2
+ import type { TreeLayout } from '@captiveorigins/onetaxonomy-layout';
3
+ /**
4
+ * Where a dragged node lands among its siblings (click-and-drag rearranging).
5
+ * Only the display order of one parent's children changes, never the tree: the
6
+ * node can't be dropped under another parent. Rectangular layouts order siblings
7
+ * by height, radial layouts by angle.
8
+ *
9
+ * `allChildren` is the parent's full child order (including children hidden by
10
+ * filters or the user); hidden ones keep their place relative to their neighbours.
11
+ * Returns the new full order, or null when nothing changes.
12
+ */
13
+ export declare function dropOrder(projection: Projection, layout: TreeLayout, id: Id, delta: {
14
+ dx: number;
15
+ dy: number;
16
+ }, allChildren: readonly Id[]): Id[] | null;
17
+ /** The node and everything drawn below it (moves together while dragging). */
18
+ export declare function drawnSubtree(projection: Projection, id: Id): Set<Id>;
19
+ /**
20
+ * Proposed mode: the node a dragged taxon is dropped onto (its new parent), or null when
21
+ * it wasn't dropped on one (a drop between siblings just reorders). A node can't land on itself or on what moves with it.
22
+ */
23
+ export declare function dropTarget(layout: TreeLayout, id: Id, moving: ReadonlySet<Id>, delta: {
24
+ dx: number;
25
+ dy: number;
26
+ }, reach?: number): Id | null;
package/dist/drag.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Where a dragged node lands among its siblings (click-and-drag rearranging).
3
+ * Only the display order of one parent's children changes, never the tree: the
4
+ * node can't be dropped under another parent. Rectangular layouts order siblings
5
+ * by height, radial layouts by angle.
6
+ *
7
+ * `allChildren` is the parent's full child order (including children hidden by
8
+ * filters or the user); hidden ones keep their place relative to their neighbours.
9
+ * Returns the new full order, or null when nothing changes.
10
+ */
11
+ export function dropOrder(projection, layout, id, delta, allChildren) {
12
+ const node = projection.nodes.find((n) => n.id === id);
13
+ if (!node?.parent)
14
+ return null;
15
+ const parent = projection.nodes.find((n) => n.id === node.parent);
16
+ const at = new Map(layout.nodes.map((n) => [n.id, n]));
17
+ const self = at.get(id);
18
+ if (!self)
19
+ return null;
20
+ const radial = layout.kind === 'radial';
21
+ const coord = (x, y, near) => {
22
+ if (!radial)
23
+ return y;
24
+ let a = Math.atan2(-y, x);
25
+ // Unwrap to the turn closest to the original angle so siblings around 0/2π compare correctly.
26
+ if (near !== undefined)
27
+ while (a - near > Math.PI)
28
+ a -= 2 * Math.PI;
29
+ if (near !== undefined)
30
+ while (near - a > Math.PI)
31
+ a += 2 * Math.PI;
32
+ return a;
33
+ };
34
+ const original = radial ? (self.angle ?? coord(self.x, self.y)) : self.y;
35
+ const dropped = coord(self.x + delta.dx, self.y + delta.dy, original);
36
+ const siblings = parent.children;
37
+ const position = (s) => (s === id ? dropped : radial ? (at.get(s).angle ?? 0) : at.get(s).y);
38
+ const visible = [...siblings].sort((a, b) => position(a) - position(b));
39
+ if (visible.every((s, i) => s === siblings[i]))
40
+ return null;
41
+ const full = allChildren.filter((c) => c !== id);
42
+ const next = visible[visible.indexOf(id) + 1];
43
+ if (next !== undefined)
44
+ full.splice(full.indexOf(next), 0, id);
45
+ else
46
+ full.splice(full.indexOf(visible[visible.length - 2]) + 1, 0, id);
47
+ return full;
48
+ }
49
+ /** The node and everything drawn below it (moves together while dragging). */
50
+ export function drawnSubtree(projection, id) {
51
+ const byId = new Map(projection.nodes.map((n) => [n.id, n]));
52
+ const out = new Set();
53
+ const stack = [id];
54
+ while (stack.length) {
55
+ const x = stack.pop();
56
+ out.add(x);
57
+ const n = byId.get(x);
58
+ if (n && !n.collapsed)
59
+ stack.push(...n.children);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Proposed mode: the node a dragged taxon is dropped onto (its new parent), or null when
65
+ * it wasn't dropped on one (a drop between siblings just reorders). A node can't land on itself or on what moves with it.
66
+ */
67
+ export function dropTarget(layout, id, moving, delta, reach = 9) {
68
+ const self = layout.nodes.find((n) => n.id === id);
69
+ if (!self)
70
+ return null;
71
+ const x = self.x + delta.dx;
72
+ const y = self.y + delta.dy;
73
+ let best = null;
74
+ let nearest = reach;
75
+ for (const n of layout.nodes) {
76
+ if (moving.has(n.id))
77
+ continue;
78
+ const d = Math.hypot(n.x - x, n.y - y);
79
+ if (d < nearest) {
80
+ nearest = d;
81
+ best = n.id;
82
+ }
83
+ }
84
+ return best;
85
+ }
@@ -0,0 +1,19 @@
1
+ import type { Citation, ViewDisclosure } from '@captiveorigins/onetaxonomy-core';
2
+ export declare const citationLine: (c: Citation) => string;
3
+ /** The disclosure as plain lines: what the diagram is, what is hidden, the data and sources. */
4
+ export declare const disclosureLines: (d: ViewDisclosure, sourcesLabel?: string) => string[];
5
+ /**
6
+ * A self-contained copy of a drawing: computed styles inlined, the viewBox kept, and
7
+ * the disclosure written underneath, so the file can't travel without saying whether
8
+ * it is a classification or a phylogeny and what was left out (I13).
9
+ */
10
+ export declare function standaloneSvg(svg: SVGSVGElement, disclosure: ViewDisclosure, { lineHeight, fontSize, sourcesLabel }?: {
11
+ lineHeight?: number;
12
+ fontSize?: number;
13
+ sourcesLabel?: string;
14
+ }): string;
15
+ /** Rasterizes a standalone SVG (from `standaloneSvg`) to PNG in the browser. */
16
+ export declare function svgToPng(svgText: string, { scale }?: {
17
+ scale?: number;
18
+ }): Promise<Blob>;
19
+ export declare function downloadBlob(blob: Blob, filename: string): void;
package/dist/export.js ADDED
@@ -0,0 +1,79 @@
1
+ /** Properties copied into exported SVGs (they carry no stylesheet). */
2
+ const INLINE = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'visibility', 'display'];
3
+ const NS = 'http://www.w3.org/2000/svg';
4
+ export const citationLine = (c) => [c.text, c.version && `version ${c.version}`, c.license, c.url, c.illustrative && 'illustrative data'].filter(Boolean).join(' · ');
5
+ /** The disclosure as plain lines: what the diagram is, what is hidden, the data and sources. */
6
+ export const disclosureLines = (d, sourcesLabel = 'Sources') => [...d.lines, ...(d.citations.length ? [`${sourcesLabel}:`, ...d.citations.map((c) => ` ${citationLine(c)}`)] : [])];
7
+ /**
8
+ * A self-contained copy of a drawing: computed styles inlined, the viewBox kept, and
9
+ * the disclosure written underneath, so the file can't travel without saying whether
10
+ * it is a classification or a phylogeny and what was left out (I13).
11
+ */
12
+ export function standaloneSvg(svg, disclosure, { lineHeight = 15, fontSize = 11, sourcesLabel } = {}) {
13
+ const copy = svg.cloneNode(true);
14
+ const src = svg.querySelectorAll('*');
15
+ copy.querySelectorAll('*').forEach((el, i) => {
16
+ const cs = getComputedStyle(src[i]);
17
+ for (const prop of INLINE) {
18
+ const v = cs.getPropertyValue(prop);
19
+ if (v)
20
+ el.style.setProperty(prop, v);
21
+ }
22
+ });
23
+ const [x, y, w, h] = (copy.getAttribute('viewBox') ?? '0 0 600 400').split(/\s+/).map(Number);
24
+ const lines = disclosureLines(disclosure, sourcesLabel);
25
+ const g = document.createElementNS(NS, 'g');
26
+ g.setAttribute('class', 'ot-disclosure');
27
+ lines.forEach((line, i) => {
28
+ const t = document.createElementNS(NS, 'text');
29
+ t.setAttribute('x', String(x + 8));
30
+ t.setAttribute('y', String(y + h + 12 + (i + 1) * lineHeight));
31
+ t.setAttribute('style', `font-family: system-ui, sans-serif; font-size: ${fontSize}px; fill: #4c312c`);
32
+ t.textContent = line;
33
+ g.appendChild(t);
34
+ });
35
+ copy.appendChild(g);
36
+ const total = h + 24 + lines.length * lineHeight;
37
+ copy.setAttribute('viewBox', `${x} ${y} ${w} ${total}`);
38
+ copy.setAttribute('width', String(Math.round(w)));
39
+ copy.setAttribute('height', String(Math.round(total)));
40
+ copy.setAttribute('xmlns', NS);
41
+ copy.removeAttribute('style');
42
+ // A white page behind the drawing, so PNGs and dark-mode exports stay legible.
43
+ const bg = document.createElementNS(NS, 'rect');
44
+ for (const [k, v] of Object.entries({ x, y, width: w, height: total, fill: '#ffffff' }))
45
+ bg.setAttribute(k, String(v));
46
+ copy.insertBefore(bg, copy.firstChild);
47
+ return new XMLSerializer().serializeToString(copy);
48
+ }
49
+ /** Rasterizes a standalone SVG (from `standaloneSvg`) to PNG in the browser. */
50
+ export async function svgToPng(svgText, { scale = 2 } = {}) {
51
+ const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
52
+ try {
53
+ const img = new Image();
54
+ img.decoding = 'async';
55
+ await new Promise((resolve, reject) => {
56
+ img.onload = () => resolve();
57
+ img.onerror = () => reject(new Error('Could not render the drawing as an image'));
58
+ img.src = url;
59
+ });
60
+ const canvas = document.createElement('canvas');
61
+ canvas.width = Math.ceil(img.naturalWidth * scale);
62
+ canvas.height = Math.ceil(img.naturalHeight * scale);
63
+ const ctx = canvas.getContext('2d');
64
+ if (!ctx)
65
+ throw new Error('Canvas is not available');
66
+ ctx.scale(scale, scale);
67
+ ctx.drawImage(img, 0, 0);
68
+ return await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG export failed'))), 'image/png'));
69
+ }
70
+ finally {
71
+ URL.revokeObjectURL(url);
72
+ }
73
+ }
74
+ export function downloadBlob(blob, filename) {
75
+ const url = URL.createObjectURL(blob);
76
+ const a = Object.assign(document.createElement('a'), { href: url, download: filename });
77
+ a.click();
78
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
79
+ }