@captiveorigins/onetaxonomy-react 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/Explorer.d.ts +48 -0
- package/dist/Explorer.js +317 -0
- package/dist/TreeOutline.d.ts +27 -0
- package/dist/TreeOutline.js +113 -0
- package/dist/TreeView.d.ts +50 -0
- package/dist/TreeView.js +57 -0
- package/dist/drag.d.ts +18 -0
- package/dist/drag.js +62 -0
- package/dist/export.d.ts +19 -0
- package/dist/export.js +79 -0
- package/dist/icons.d.ts +38 -0
- package/dist/icons.js +36 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/styles.css +140 -0
- package/package.json +62 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Captive Origins and OneTaxonomy contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @captiveorigins/onetaxonomy-react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React components for OneTaxonomy: a server-renderable SVG tree, an accessible keyboard outline of the same tree, and an explorer with undo/redo. Every interaction is a view action from `@captiveorigins/onetaxonomy-core`, so the scientific data never changes and views can be saved, shared and replayed.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { createView, indexDataset } from '@captiveorigins/onetaxonomy-core'
|
|
7
|
+
import { Explorer } from '@captiveorigins/onetaxonomy-react'
|
|
8
|
+
import '@captiveorigins/onetaxonomy-react/styles.css'
|
|
9
|
+
|
|
10
|
+
const data = indexDataset(snapshot)
|
|
11
|
+
const view = createView(data, { id: 'turtles', title: 'Turtles', target: { kind: 'classification', id: 'my-classification' } })
|
|
12
|
+
|
|
13
|
+
<Explorer dataset={data} initialView={view} title="Turtles" onViewChange={save} />
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **`TreeView`**: SVG only, no effects: renders on the server. `role="img"` with a title and a description that says whether it is a classification or a phylogeny and how much is shown. Stable `ot-*` classes and `data-*` states (status, rank, collapsed, selected); extinct taxa are marked †.
|
|
17
|
+
- **`TreeOutline`**: the WAI-ARIA tree pattern: one tab stop, arrows, Right/Left to open/close, Home/End, type-ahead, Space to select, **Alt+Up/Down to reorder** (the keyboard alternative to dragging). Folded descendants are announced ("12 not shown").
|
|
18
|
+
- **`Explorer`**: toolbar (undo, redo, expand/collapse all, layout, living/extinct/unknown filter), drawing, outline and inspector (rank, status, highlights with *clade* or *group* and the hypothesis they were assessed against, flip children, show only this branch). Layouts the data can't support stay disabled with the reason. All text is replaceable through `labels`.
|
|
19
|
+
- **`useTaxonomyView`**: the headless state (view, projection, dispatch, undo/redo) for your own UI.
|
|
20
|
+
- Styles are optional (`styles.css`, CSS custom properties, dark mode, reduced motion).
|
|
21
|
+
|
|
22
|
+
Try it: `pnpm build && pnpm --filter @captiveorigins/onetaxonomy-playground build`, then open `apps/playground/dist/index.html`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type DatasetIndex, type Highlight, type Id, type LayoutKind, type Projection, type TaxonomicView, type TraitSet, type ViewAction } from '@captiveorigins/onetaxonomy-core';
|
|
2
|
+
import { type TreeLayout } from '@captiveorigins/onetaxonomy-layout';
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
|
+
export type UseTaxonomyView = {
|
|
5
|
+
view: TaxonomicView;
|
|
6
|
+
projection: Projection;
|
|
7
|
+
dispatch: (action: ViewAction) => void;
|
|
8
|
+
undo: () => void;
|
|
9
|
+
redo: () => void;
|
|
10
|
+
canUndo: boolean;
|
|
11
|
+
canRedo: boolean;
|
|
12
|
+
/** The last refused action's reason (e.g. a layout the data can't support). */
|
|
13
|
+
error: string | null;
|
|
14
|
+
};
|
|
15
|
+
/** View state with undo/redo. Every change goes through the pure core reducer. */
|
|
16
|
+
export declare function useTaxonomyView(dataset: DatasetIndex, initial: TaxonomicView, { onChange }?: {
|
|
17
|
+
onChange?: (view: TaxonomicView) => void;
|
|
18
|
+
}): UseTaxonomyView;
|
|
19
|
+
/** Lays out a projection for a view's layout kind (sunburst is drawn as a rectangular outline here). */
|
|
20
|
+
export declare function layoutFor(projection: Projection, kind: LayoutKind): TreeLayout;
|
|
21
|
+
export type ExplorerLabels = Record<'undo' | 'redo' | 'expandAll' | 'collapseAll' | 'rotate' | 'layout' | 'status' | 'allStatuses' | 'outline' | 'details' | 'nothingSelected' | 'clade' | 'group' | 'hidden' | 'rank' | 'lifeStatus' | 'focus' | 'unfocus' | 'zoomIn' | 'zoomOut' | 'fit' | 'download' | 'print' | 'canvas' | 'controls' | 'dragHint' | 'overlays' | 'support' | 'extinction' | 'trait' | 'noTrait' | 'noStatedValue' | 'mixed' | 'weakSupport' | 'selection' | 'checkClade' | 'addHighlight' | 'highlightName' | 'checkAgainst' | 'clearSelection' | 'remove' | 'multiHint' | 'downloadSvg' | 'downloadPng' | 'downloadView' | 'about' | 'sources' | 'references' | 'synonyms' | 'commonNames' | 'age' | 'branchLength' | 'unmapped' | 'traits', string>;
|
|
22
|
+
export type ExplorerProps = {
|
|
23
|
+
dataset: DatasetIndex;
|
|
24
|
+
initialView: TaxonomicView;
|
|
25
|
+
title: string;
|
|
26
|
+
/** Height of the drawing area (CSS length). Default 32rem. */
|
|
27
|
+
height?: string;
|
|
28
|
+
labels?: Partial<ExplorerLabels>;
|
|
29
|
+
onViewChange?: (view: TaxonomicView) => void;
|
|
30
|
+
/** Extra content in the inspector for the selected node (e.g. a link to its page). */
|
|
31
|
+
renderDetails?: (id: Id) => ReactNode;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Interactive explorer: the drawing, the accessible outline, a toolbar (undo,
|
|
35
|
+
* redo, expand/collapse, flip, layout, life-status filter) and an inspector. Every
|
|
36
|
+
* change is a view action over the pinned snapshot; the data itself never changes.
|
|
37
|
+
*/
|
|
38
|
+
export declare function Explorer({ dataset, initialView, title, height, labels: custom, onViewChange, renderDetails }: ExplorerProps): import("react").JSX.Element;
|
|
39
|
+
/** What the drawing is (classification vs phylogeny) and what each highlight is. */
|
|
40
|
+
export declare function Legend({ layout, highlights, labels: custom, trait, support, onRemoveHighlight, }: {
|
|
41
|
+
layout: TreeLayout;
|
|
42
|
+
highlights: Highlight[];
|
|
43
|
+
labels?: Partial<Pick<ExplorerLabels, 'clade' | 'group' | 'noStatedValue' | 'weakSupport' | 'mixed' | 'remove'>>;
|
|
44
|
+
/** The trait set the drawing is colored by (its states become legend entries). */
|
|
45
|
+
trait?: TraitSet | undefined;
|
|
46
|
+
support?: boolean;
|
|
47
|
+
onRemoveHighlight?: (id: Id) => void;
|
|
48
|
+
}): import("react").JSX.Element;
|
package/dist/Explorer.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { createHistory, describeAssessment, describeView, makeHighlight, dispatch as dispatchAction, inspectNode, nodeOverlays, serializeView, layoutProblem, projectView, redo as redoHistory, undo as undoHistory, } from '@captiveorigins/onetaxonomy-core';
|
|
4
|
+
import { radial, rectangular } from '@captiveorigins/onetaxonomy-layout';
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { dropOrder, drawnSubtree } from './drag.js';
|
|
7
|
+
import { citationLine, disclosureLines, downloadBlob, standaloneSvg, svgToPng } from './export.js';
|
|
8
|
+
import { Icon } from './icons.js';
|
|
9
|
+
import { TreeOutline } from './TreeOutline.js';
|
|
10
|
+
import { treeViewBox, TreeView } from './TreeView.js';
|
|
11
|
+
/** View state with undo/redo. Every change goes through the pure core reducer. */
|
|
12
|
+
export function useTaxonomyView(dataset, initial, { onChange } = {}) {
|
|
13
|
+
const [history, setHistory] = useState(() => createHistory(initial));
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
const update = useCallback((next) => setHistory((h) => {
|
|
16
|
+
const n = next(h);
|
|
17
|
+
if (n !== h)
|
|
18
|
+
onChange?.(n.present);
|
|
19
|
+
return n;
|
|
20
|
+
}), [onChange]);
|
|
21
|
+
const dispatch = useCallback((action) => {
|
|
22
|
+
try {
|
|
23
|
+
// Validate outside the state updater so the error can be shown.
|
|
24
|
+
dispatchAction(history, action, dataset);
|
|
25
|
+
setError(null);
|
|
26
|
+
update((h) => dispatchAction(h, action, dataset));
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
setError(e.message);
|
|
30
|
+
}
|
|
31
|
+
}, [dataset, history, update]);
|
|
32
|
+
const projection = useMemo(() => projectView(dataset, history.present), [dataset, history.present]);
|
|
33
|
+
return {
|
|
34
|
+
view: history.present,
|
|
35
|
+
projection,
|
|
36
|
+
dispatch,
|
|
37
|
+
undo: () => update(undoHistory),
|
|
38
|
+
redo: () => update(redoHistory),
|
|
39
|
+
canUndo: history.past.length > 0,
|
|
40
|
+
canRedo: history.future.length > 0,
|
|
41
|
+
error,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Lays out a projection for a view's layout kind (sunburst is drawn as a rectangular outline here). */
|
|
45
|
+
export function layoutFor(projection, kind) {
|
|
46
|
+
if (kind === 'radial')
|
|
47
|
+
return radial(projection);
|
|
48
|
+
if (kind === 'phylogram' || kind === 'time')
|
|
49
|
+
return rectangular(projection, { scale: kind, width: 640 });
|
|
50
|
+
return rectangular(projection);
|
|
51
|
+
}
|
|
52
|
+
const LABELS = {
|
|
53
|
+
undo: 'Undo',
|
|
54
|
+
redo: 'Redo',
|
|
55
|
+
expandAll: 'Expand all',
|
|
56
|
+
collapseAll: 'Collapse all',
|
|
57
|
+
rotate: 'Flip children',
|
|
58
|
+
layout: 'Layout',
|
|
59
|
+
status: 'Show',
|
|
60
|
+
allStatuses: 'Living, extinct and unknown',
|
|
61
|
+
outline: 'Tree outline',
|
|
62
|
+
details: 'Details',
|
|
63
|
+
nothingSelected: 'Choose a taxon in the tree or the outline.',
|
|
64
|
+
clade: 'Clade',
|
|
65
|
+
group: 'Group',
|
|
66
|
+
hidden: 'not shown',
|
|
67
|
+
rank: 'Rank',
|
|
68
|
+
lifeStatus: 'Status',
|
|
69
|
+
focus: 'Show only this branch',
|
|
70
|
+
unfocus: 'Show everything',
|
|
71
|
+
zoomIn: 'Zoom in',
|
|
72
|
+
zoomOut: 'Zoom out',
|
|
73
|
+
fit: 'Fit to view',
|
|
74
|
+
download: 'Download',
|
|
75
|
+
print: 'Print',
|
|
76
|
+
canvas: 'Tree drawing: drag to pan, Ctrl or ⌘ + scroll to zoom, drag a taxon to reorder it among its siblings, double-click to fold',
|
|
77
|
+
controls: 'Drawing controls',
|
|
78
|
+
dragHint: 'Drag a taxon up or down to reorder it among its siblings; double-click a branch to fold it.',
|
|
79
|
+
overlays: 'Overlays',
|
|
80
|
+
support: 'Support values',
|
|
81
|
+
extinction: 'Extinct and fossils',
|
|
82
|
+
trait: 'Color by',
|
|
83
|
+
noTrait: 'No trait',
|
|
84
|
+
noStatedValue: 'No stated value',
|
|
85
|
+
mixed: 'Mixed states',
|
|
86
|
+
weakSupport: 'Weak support (dashed)',
|
|
87
|
+
selection: 'Selected',
|
|
88
|
+
checkClade: 'Is this a clade?',
|
|
89
|
+
addHighlight: 'Highlight',
|
|
90
|
+
highlightName: 'Name',
|
|
91
|
+
checkAgainst: 'Check against',
|
|
92
|
+
clearSelection: 'Clear selection',
|
|
93
|
+
remove: 'Remove',
|
|
94
|
+
multiHint: 'Shift-click (or Ctrl/⌘) to select several taxa and check whether they form a clade.',
|
|
95
|
+
downloadSvg: 'Drawing (SVG)',
|
|
96
|
+
downloadPng: 'Image (PNG)',
|
|
97
|
+
downloadView: 'View file (JSON)',
|
|
98
|
+
about: 'About this diagram',
|
|
99
|
+
sources: 'Sources',
|
|
100
|
+
references: 'References',
|
|
101
|
+
synonyms: 'Synonyms',
|
|
102
|
+
commonNames: 'Common names',
|
|
103
|
+
age: 'Age',
|
|
104
|
+
branchLength: 'Branch length',
|
|
105
|
+
unmapped: 'Not linked to a named taxon (a sample, population or unnamed lineage).',
|
|
106
|
+
traits: 'Traits',
|
|
107
|
+
};
|
|
108
|
+
/** Prints only this drawing: everything else on the page is hidden while printing. */
|
|
109
|
+
function printDrawing(el) {
|
|
110
|
+
if (!el)
|
|
111
|
+
return window.print();
|
|
112
|
+
el.setAttribute('data-ot-printing', '');
|
|
113
|
+
document.documentElement.setAttribute('data-ot-printing', '');
|
|
114
|
+
const done = () => {
|
|
115
|
+
el.removeAttribute('data-ot-printing');
|
|
116
|
+
document.documentElement.removeAttribute('data-ot-printing');
|
|
117
|
+
window.removeEventListener('afterprint', done);
|
|
118
|
+
};
|
|
119
|
+
window.addEventListener('afterprint', done);
|
|
120
|
+
window.print();
|
|
121
|
+
}
|
|
122
|
+
const LAYOUTS = ['rectangular', 'radial', 'phylogram', 'time'];
|
|
123
|
+
const STATUS_FILTERS = [{ value: 'all' }, { value: 'extant', statuses: ['extant'] }, { value: 'extinct', statuses: ['extinct'] }, { value: 'unknown', statuses: ['unknown'] }];
|
|
124
|
+
/**
|
|
125
|
+
* Interactive explorer: the drawing, the accessible outline, a toolbar (undo,
|
|
126
|
+
* redo, expand/collapse, flip, layout, life-status filter) and an inspector. Every
|
|
127
|
+
* change is a view action over the pinned snapshot; the data itself never changes.
|
|
128
|
+
*/
|
|
129
|
+
export function Explorer({ dataset, initialView, title, height = '32rem', labels: custom, onViewChange, renderDetails }) {
|
|
130
|
+
const labels = { ...LABELS, ...custom };
|
|
131
|
+
const t = useTaxonomyView(dataset, initialView, onViewChange ? { onChange: onViewChange } : {});
|
|
132
|
+
const { view, projection, dispatch } = t;
|
|
133
|
+
const layout = useMemo(() => layoutFor(projection, view.layout.kind), [projection, view.layout.kind]);
|
|
134
|
+
const selected = view.selected[0] ?? null;
|
|
135
|
+
const node = selected ? projection.nodes.find((n) => n.id === selected) : undefined;
|
|
136
|
+
const statusValue = STATUS_FILTERS.find((f) => JSON.stringify(f.statuses ?? null) === JSON.stringify(view.filter.status ?? null))?.value ?? 'all';
|
|
137
|
+
const hidden = (n) => `${n} ${labels.hidden}`;
|
|
138
|
+
const marks = useMemo(() => nodeOverlays(dataset, view, projection, { mixedLabel: labels.mixed }), [dataset, view, projection, labels.mixed]);
|
|
139
|
+
const disclosure = useMemo(() => describeView(dataset, view, projection), [dataset, view, projection]);
|
|
140
|
+
const details = useMemo(() => (selected && node ? inspectNode(dataset, view, selected) : null), [dataset, view, selected, node]);
|
|
141
|
+
const traits = dataset.snapshot.traits ?? [];
|
|
142
|
+
const hasSupport = projection.nodes.some((n) => n.support !== undefined);
|
|
143
|
+
const hasExtinct = projection.nodes.some((n) => n.status === 'extinct');
|
|
144
|
+
const overlays = view.overlays ?? {};
|
|
145
|
+
const setOverlays = (next) => dispatch({ type: 'setOverlays', overlays: next });
|
|
146
|
+
const choose = (id, additive = false) => dispatch({ type: 'select', nodes: additive ? (view.selected.includes(id) ? view.selected.filter((x) => x !== id) : [...view.selected, id]) : [id] });
|
|
147
|
+
// Pan and zoom (same controls as OnePedigree): the box resets when the layout or focus changes.
|
|
148
|
+
const full = useMemo(() => treeViewBox(layout), [layout]);
|
|
149
|
+
const [box, setBox] = useState(null);
|
|
150
|
+
const shown = box ?? full;
|
|
151
|
+
useEffect(() => setBox(null), [view.layout.kind, view.focus, view.target.id]);
|
|
152
|
+
const canvas = useRef(null);
|
|
153
|
+
const pointer = useRef(null);
|
|
154
|
+
const [drag, setDrag] = useState(null);
|
|
155
|
+
const scale = () => {
|
|
156
|
+
const r = canvas.current?.querySelector('svg.ot-root')?.getBoundingClientRect();
|
|
157
|
+
if (!r || !r.width || !r.height)
|
|
158
|
+
return 1;
|
|
159
|
+
return Math.max(shown.width / r.width, shown.height / r.height);
|
|
160
|
+
};
|
|
161
|
+
const zoom = (factor) => {
|
|
162
|
+
const cx = shown.x + shown.width / 2;
|
|
163
|
+
const cy = shown.y + shown.height / 2;
|
|
164
|
+
setBox({ x: cx - (shown.width * factor) / 2, y: cy - (shown.height * factor) / 2, width: shown.width * factor, height: shown.height * factor });
|
|
165
|
+
};
|
|
166
|
+
const pan = (fx, fy) => setBox({ ...shown, x: shown.x + shown.width * fx, y: shown.y + shown.height * fy });
|
|
167
|
+
const allChildren = (parent) => view.order[parent] ?? viewChildren(dataset, view, parent);
|
|
168
|
+
const move = (id, direction) => {
|
|
169
|
+
const n = projection.nodes.find((x) => x.id === id);
|
|
170
|
+
if (!n?.parent)
|
|
171
|
+
return;
|
|
172
|
+
const siblings = [...allChildren(n.parent)];
|
|
173
|
+
const i = siblings.indexOf(id);
|
|
174
|
+
const j = i + direction;
|
|
175
|
+
if (i < 0 || j < 0 || j >= siblings.length)
|
|
176
|
+
return;
|
|
177
|
+
[siblings[i], siblings[j]] = [siblings[j], siblings[i]];
|
|
178
|
+
dispatch({ type: 'reorder', node: n.parent, children: siblings });
|
|
179
|
+
};
|
|
180
|
+
const onPointerDown = (e) => {
|
|
181
|
+
const target = e.target;
|
|
182
|
+
if (target.closest('button, a, select, .ot-floating'))
|
|
183
|
+
return;
|
|
184
|
+
const nodeId = target.closest('.ot-node')?.getAttribute('data-id') ?? null;
|
|
185
|
+
pointer.current = { x: e.clientX, y: e.clientY, box: shown, node: nodeId, moved: false, additive: e.shiftKey || e.ctrlKey || e.metaKey };
|
|
186
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
187
|
+
};
|
|
188
|
+
const onPointerMove = (e) => {
|
|
189
|
+
const p = pointer.current;
|
|
190
|
+
if (!p)
|
|
191
|
+
return;
|
|
192
|
+
const k = scale();
|
|
193
|
+
const dx = (e.clientX - p.x) * k;
|
|
194
|
+
const dy = (e.clientY - p.y) * k;
|
|
195
|
+
if (!p.moved && Math.hypot(e.clientX - p.x, e.clientY - p.y) < 4)
|
|
196
|
+
return;
|
|
197
|
+
p.moved = true;
|
|
198
|
+
const n = p.node ? projection.nodes.find((x) => x.id === p.node) : undefined;
|
|
199
|
+
if (n?.parent)
|
|
200
|
+
setDrag({ id: n.id, ids: drag?.id === n.id ? drag.ids : drawnSubtree(projection, n.id), dx, dy });
|
|
201
|
+
else
|
|
202
|
+
setBox({ ...p.box, x: p.box.x - dx, y: p.box.y - dy });
|
|
203
|
+
};
|
|
204
|
+
const onPointerUp = () => {
|
|
205
|
+
const p = pointer.current;
|
|
206
|
+
pointer.current = null;
|
|
207
|
+
if (!p)
|
|
208
|
+
return;
|
|
209
|
+
if (drag) {
|
|
210
|
+
const parent = projection.nodes.find((n) => n.id === drag.id)?.parent;
|
|
211
|
+
const order = parent ? dropOrder(projection, layout, drag.id, drag, allChildren(parent)) : null;
|
|
212
|
+
setDrag(null);
|
|
213
|
+
if (order && parent)
|
|
214
|
+
dispatch({ type: 'reorder', node: parent, children: order });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!p.moved && p.node)
|
|
218
|
+
choose(p.node, p.additive);
|
|
219
|
+
};
|
|
220
|
+
const onDoubleClick = (e) => {
|
|
221
|
+
const id = e.target.closest('.ot-node')?.getAttribute('data-id');
|
|
222
|
+
const n = id ? projection.nodes.find((x) => x.id === id) : undefined;
|
|
223
|
+
if (n && !n.isTip)
|
|
224
|
+
dispatch({ type: 'toggle', node: n.id });
|
|
225
|
+
};
|
|
226
|
+
const onWheel = (e) => {
|
|
227
|
+
// Zoom only with Ctrl/⌘ (and trackpad pinch, which sets ctrlKey); plain scrolling scrolls the page.
|
|
228
|
+
if (!e.ctrlKey && !e.metaKey)
|
|
229
|
+
return;
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
zoom(e.deltaY > 0 ? 1.1 : 0.9);
|
|
232
|
+
};
|
|
233
|
+
const onKeyDown = (e) => {
|
|
234
|
+
if (e.target !== e.currentTarget)
|
|
235
|
+
return;
|
|
236
|
+
if (e.key === '+' || e.key === '=')
|
|
237
|
+
zoom(0.8);
|
|
238
|
+
else if (e.key === '-')
|
|
239
|
+
zoom(1.25);
|
|
240
|
+
else if (e.key === '0')
|
|
241
|
+
setBox(null);
|
|
242
|
+
else if (e.key.startsWith('Arrow')) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
pan(e.key === 'ArrowLeft' ? -0.1 : e.key === 'ArrowRight' ? 0.1 : 0, e.key === 'ArrowUp' ? -0.1 : e.key === 'ArrowDown' ? 0.1 : 0);
|
|
245
|
+
}
|
|
246
|
+
else if (e.key === 'Escape')
|
|
247
|
+
dispatch({ type: 'select', nodes: [] });
|
|
248
|
+
};
|
|
249
|
+
const exported = () => {
|
|
250
|
+
const svg = canvas.current?.querySelector('svg.ot-root');
|
|
251
|
+
return svg ? standaloneSvg(svg, disclosure, { sourcesLabel: labels.sources }) : null;
|
|
252
|
+
};
|
|
253
|
+
const [menu, setMenu] = useState(false);
|
|
254
|
+
const download = async (kind) => {
|
|
255
|
+
setMenu(false);
|
|
256
|
+
if (kind === 'view')
|
|
257
|
+
return downloadBlob(new Blob([serializeView(view)], { type: 'application/json' }), `${view.id}.onetaxonomy-view.json`);
|
|
258
|
+
const text = exported();
|
|
259
|
+
if (!text)
|
|
260
|
+
return;
|
|
261
|
+
if (kind === 'svg')
|
|
262
|
+
return downloadBlob(new Blob([text], { type: 'image/svg+xml' }), `${view.id}.svg`);
|
|
263
|
+
downloadBlob(await svgToPng(text), `${view.id}.png`);
|
|
264
|
+
};
|
|
265
|
+
return (_jsxs("div", { className: "ot-explorer", children: [_jsxs("div", { className: "ot-toolbar", role: "toolbar", "aria-label": title, children: [_jsxs("button", { type: "button", onClick: t.undo, disabled: !t.canUndo, children: [_jsx(Icon, { name: "undo" }), labels.undo] }), _jsxs("button", { type: "button", onClick: t.redo, disabled: !t.canRedo, children: [_jsx(Icon, { name: "redo" }), labels.redo] }), _jsx("span", { className: "ot-toolbar-gap", "aria-hidden": "true" }), _jsxs("button", { type: "button", onClick: () => dispatch({ type: 'expandAll' }), children: [_jsx(Icon, { name: "expand" }), labels.expandAll] }), _jsxs("button", { type: "button", onClick: () => dispatch({ type: 'collapseAll' }), children: [_jsx(Icon, { name: "collapse" }), labels.collapseAll] }), _jsx("span", { className: "ot-toolbar-gap", "aria-hidden": "true" }), _jsxs("label", { className: "ot-select", children: [_jsx(Icon, { name: "layout" }), _jsx("span", { children: labels.layout }), _jsx("select", { value: view.layout.kind, onChange: (e) => dispatch({ type: 'setLayout', layout: e.target.value }), children: LAYOUTS.map((k) => {
|
|
266
|
+
const problem = layoutProblem(dataset, view.target, k);
|
|
267
|
+
return (_jsx("option", { value: k, disabled: Boolean(problem), title: problem ?? undefined, children: k }, k));
|
|
268
|
+
}) })] }), _jsxs("label", { className: "ot-select", children: [_jsx(Icon, { name: "filter" }), _jsx("span", { children: labels.status }), _jsx("select", { value: statusValue, onChange: (e) => {
|
|
269
|
+
const f = STATUS_FILTERS.find((x) => x.value === e.target.value);
|
|
270
|
+
const { status: _, ...rest } = view.filter;
|
|
271
|
+
dispatch({ type: 'setFilter', filter: f.statuses ? { ...rest, status: f.statuses } : rest });
|
|
272
|
+
}, children: STATUS_FILTERS.map((f) => (_jsx("option", { value: f.value, children: f.value === 'all' ? labels.allStatuses : f.value }, f.value))) })] }), (hasSupport || hasExtinct || traits.length > 0) && _jsx("span", { className: "ot-toolbar-gap", "aria-hidden": "true" }), hasSupport && (_jsxs("button", { type: "button", "aria-pressed": Boolean(overlays.support), onClick: () => { const { support, ...rest } = overlays; setOverlays(support ? rest : { ...rest, support: {} }); }, children: [_jsx(Icon, { name: "check" }), labels.support] })), hasExtinct && (_jsxs("button", { type: "button", "aria-pressed": Boolean(overlays.extinction), onClick: () => { const { extinction, ...rest } = overlays; setOverlays(extinction ? rest : { ...rest, extinction: true }); }, children: [_jsx(Icon, { name: "dagger" }), labels.extinction] })), traits.length > 0 && (_jsxs("label", { className: "ot-select", children: [_jsx(Icon, { name: "palette" }), _jsx("span", { children: labels.trait }), _jsxs("select", { value: overlays.trait ?? '', onChange: (e) => { const { trait: _, ...rest } = overlays; setOverlays(e.target.value ? { ...rest, trait: e.target.value } : rest); }, children: [_jsx("option", { value: "", children: labels.noTrait }), traits.map((tr) => (_jsx("option", { value: tr.id, children: tr.title }, tr.id)))] })] }))] }), t.error && (_jsx("p", { className: "ot-error", role: "status", children: t.error })), _jsxs("div", { className: "ot-panes", children: [_jsxs("div", { ref: canvas, className: "ot-canvas", style: { height }, tabIndex: 0, role: "group", "aria-label": labels.canvas, "data-dragging": drag ? '' : undefined, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: () => {
|
|
273
|
+
pointer.current = null;
|
|
274
|
+
setDrag(null);
|
|
275
|
+
}, onDoubleClick: onDoubleClick, onWheel: onWheel, onKeyDown: onKeyDown, children: [_jsxs("div", { className: "ot-floating", 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(null), "aria-label": labels.fit, title: labels.fit, children: _jsx(Icon, { name: "fit" }) }), _jsxs("div", { className: "ot-menu", children: [_jsx("button", { type: "button", onClick: () => setMenu((m) => !m), "aria-label": labels.download, title: labels.download, "aria-expanded": menu, "aria-haspopup": "menu", children: _jsx(Icon, { name: "download" }) }), menu && (_jsxs("div", { className: "ot-menu-list", role: "menu", onKeyDown: (e) => e.key === 'Escape' && setMenu(false), children: [_jsxs("button", { type: "button", role: "menuitem", onClick: () => download('svg'), children: [_jsx(Icon, { name: "download" }), labels.downloadSvg] }), _jsxs("button", { type: "button", role: "menuitem", onClick: () => download('png'), children: [_jsx(Icon, { name: "image" }), labels.downloadPng] }), _jsxs("button", { type: "button", role: "menuitem", onClick: () => download('view'), children: [_jsx(Icon, { name: "file" }), labels.downloadView] })] }))] }), _jsx("button", { type: "button", onClick: () => printDrawing(canvas.current), "aria-label": labels.print, title: labels.print, children: _jsx(Icon, { name: "print" }) })] }), _jsx(TreeView, { projection: projection, layout: layout, highlights: view.highlights, title: title, interactive: true, hiddenLabel: hidden, viewBox: shown, drag: drag, overlays: marks }), _jsx("div", { className: "ot-print-disclosure", "aria-hidden": "true", children: disclosureLines(disclosure, labels.sources).map((l, i) => (_jsx("p", { children: l }, i))) })] }), _jsxs("div", { className: "ot-side", children: [_jsx(TreeOutline, { projection: projection, label: labels.outline, hiddenLabel: hidden, onToggle: (id) => dispatch({ type: 'toggle', node: id }), onSelect: (id, o) => choose(id, o?.additive), onMove: move }), _jsx("section", { className: "ot-inspector", "aria-label": labels.details, children: view.selected.length > 1 ? (_jsx(CladeCheck, { dataset: dataset, view: view, projection: projection, dispatch: dispatch, labels: labels })) : node ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "ot-inspector-title", children: node.label }), _jsxs("dl", { children: [node.rank && (_jsxs(_Fragment, { children: [_jsx("dt", { children: labels.rank }), _jsx("dd", { children: node.rank })] })), _jsx("dt", { children: labels.lifeStatus }), _jsx("dd", { children: node.status }), details?.age && (_jsxs(_Fragment, { children: [_jsx("dt", { children: labels.age }), _jsxs("dd", { children: [details.age.value, details.age.min !== undefined && details.age.max !== undefined ? ` (${details.age.min}–${details.age.max})` : '', " ", details.age.unit !== 'unspecified' ? details.age.unit : ''] })] })), details?.support && (_jsxs(_Fragment, { children: [_jsx("dt", { children: labels.support }), _jsxs("dd", { children: [details.support.value, details.support.method ? ` (${details.support.method})` : ''] })] })), details?.branchLength && (_jsxs(_Fragment, { children: [_jsx("dt", { children: labels.branchLength }), _jsxs("dd", { children: [details.branchLength.value, " ", details.branchLength.unit !== 'unspecified' ? details.branchLength.unit : ''] })] }))] }), details && _jsx(NodeFacts, { details: details, labels: labels }), node.hiddenDescendants > 0 && _jsx("p", { children: hidden(node.hiddenDescendants) }), _jsx(HighlightList, { highlights: view.highlights.filter((h) => node.highlights.includes(h.id)), labels: labels }), _jsxs("div", { className: "ot-inspector-actions", children: [!node.isTip && (_jsxs("button", { type: "button", onClick: () => dispatch({ type: 'rotate', node: node.id }), children: [_jsx(Icon, { name: "flip" }), labels.rotate] })), _jsxs("button", { type: "button", onClick: () => dispatch({ type: 'focus', node: view.focus === node.id ? null : node.id }), children: [_jsx(Icon, { name: view.focus === node.id ? 'unfocus' : 'focus' }), view.focus === node.id ? labels.unfocus : labels.focus] })] }), (view.target.kind === 'classification' && dataset.snapshot.phylogenies.length > 0) && _jsx(CladeCheck, { dataset: dataset, view: view, projection: projection, dispatch: dispatch, labels: labels, single: true }), renderDetails?.(node.id)] })) : (_jsxs("p", { className: "ot-hint", children: [_jsx(Icon, { name: "info" }), labels.nothingSelected, " ", labels.dragHint, " ", labels.multiHint] })) })] })] }), _jsx(Legend, { layout: layout, highlights: view.highlights, labels: labels, trait: traits.find((tr) => tr.id === overlays.trait), support: Boolean(overlays.support), onRemoveHighlight: (id) => dispatch({ type: 'removeHighlight', id }) }), _jsxs("details", { className: "ot-about", children: [_jsxs("summary", { children: [_jsx(Icon, { name: "book" }), labels.about] }), _jsx("ul", { children: disclosure.lines.map((l, i) => (_jsx("li", { children: l }, i))) }), disclosure.citations.length > 0 && (_jsxs(_Fragment, { children: [_jsx("h4", { children: labels.sources }), _jsx("ul", { children: disclosure.citations.map((c) => (_jsx("li", { children: citationLine(c) }, c.source))) })] }))] })] }));
|
|
276
|
+
}
|
|
277
|
+
const PALETTE = ['#cf8137', '#2f7d5c', '#91645d', '#3b6fb6', '#b9a8d9', '#d9a400'];
|
|
278
|
+
/**
|
|
279
|
+
* The clade selector: check whether the selected taxa form a clade in the hypothesis
|
|
280
|
+
* (or, for one taxon of a classification, in a chosen phylogeny), with the reason in
|
|
281
|
+
* words, then keep it as a highlight: a clade only when supported, otherwise a group.
|
|
282
|
+
*/
|
|
283
|
+
function CladeCheck({ dataset, view, projection, dispatch, labels, single = false }) {
|
|
284
|
+
const phylogenies = dataset.snapshot.phylogenies;
|
|
285
|
+
const [against, setAgainst] = useState(phylogenies[0]?.id ?? '');
|
|
286
|
+
const [name, setName] = useState('');
|
|
287
|
+
const members = view.selected;
|
|
288
|
+
const result = useMemo(() => {
|
|
289
|
+
if (view.target.kind === 'classification' && members.length !== 1)
|
|
290
|
+
return null;
|
|
291
|
+
return makeHighlight(dataset, view, { id: `h-${members.join('-')}`, label: name || members.map((m) => projection.nodes.find((n) => n.id === m)?.label ?? m).join(', '), members, ...(view.target.kind === 'classification' && against ? { hypothesis: against } : {}) });
|
|
292
|
+
}, [dataset, view, members, name, against, projection]);
|
|
293
|
+
const words = result?.assessment ? describeAssessment(dataset, result.assessment) : null;
|
|
294
|
+
const nameOf = (id) => projection.nodes.find((n) => n.id === id)?.label ?? id;
|
|
295
|
+
return (_jsxs("div", { className: "ot-clade-check", "data-status": result?.assessment?.status, children: [!single && (_jsxs(_Fragment, { children: [_jsxs("h3", { className: "ot-inspector-title", children: [labels.selection, " (", members.length, ")"] }), _jsx("p", { className: "ot-selection", children: members.map(nameOf).join(', ') })] })), view.target.kind === 'classification' && (_jsxs("label", { className: "ot-select", children: [_jsx("span", { children: labels.checkAgainst }), _jsx("select", { value: against, onChange: (e) => setAgainst(e.target.value), children: phylogenies.map((p) => (_jsx("option", { value: p.id, children: p.title }, p.id))) })] })), words && (_jsxs("div", { className: "ot-assessment-box", role: "status", children: [_jsxs("p", { children: [_jsx("strong", { children: labels.checkClade }), " ", words.headline] }), words.details.map((d, i) => (_jsx("p", { className: "ot-hint", children: d }, i)))] })), result && (_jsxs("form", { className: "ot-inspector-actions", onSubmit: (e) => {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
const id = `h${Date.now().toString(36)}`;
|
|
298
|
+
dispatch({ type: 'addHighlight', highlight: { ...result, id, color: PALETTE[view.highlights.length % PALETTE.length] } });
|
|
299
|
+
setName('');
|
|
300
|
+
}, children: [_jsxs("label", { className: "ot-select", children: [_jsx("span", { children: labels.highlightName }), _jsx("input", { value: name, onChange: (e) => setName(e.target.value), placeholder: result.label })] }), _jsxs("button", { type: "submit", children: [_jsx(Icon, { name: "palette" }), labels.addHighlight, " (", result.kind === 'clade' ? labels.clade : labels.group, ")"] })] })), !single && (_jsxs("button", { type: "button", onClick: () => dispatch({ type: 'select', nodes: [] }), children: [_jsx(Icon, { name: "close" }), labels.clearSelection] }))] }));
|
|
301
|
+
}
|
|
302
|
+
function NodeFacts({ details, labels }) {
|
|
303
|
+
const c = details.concepts.length === 1 ? details.concepts[0] : undefined;
|
|
304
|
+
const profile = c?.profile;
|
|
305
|
+
return (_jsxs("div", { className: "ot-facts", children: [details.unmapped && _jsx("p", { className: "ot-hint", children: labels.unmapped }), c?.authorship && _jsxs("p", { className: "ot-authorship", children: [c.name, " ", c.authorship] }), profile?.summary && _jsx("p", { children: profile.summary }), c && c.synonyms.length > 0 && (_jsxs("p", { children: [_jsxs("span", { className: "ot-fact-label", children: [labels.synonyms, ":"] }), " ", c.synonyms.join(', ')] })), c && c.commonNames.length > 0 && (_jsxs("p", { children: [_jsxs("span", { className: "ot-fact-label", children: [labels.commonNames, ":"] }), " ", c.commonNames.map((n) => `${n.name} (${n.locale})`).join(', ')] })), details.traits.length > 0 && (_jsxs("p", { children: [_jsxs("span", { className: "ot-fact-label", children: [labels.traits, ":"] }), " ", details.traits.map((tr) => `${tr.title}: ${tr.state}${tr.note ? ` (${tr.note})` : ''}`).join('; ')] })), details.references.length > 0 && (_jsxs("p", { children: [_jsxs("span", { className: "ot-fact-label", children: [labels.references, ":"] }), " ", details.references.join('; ')] })), details.citations.length > 0 && (_jsxs("p", { className: "ot-citations", children: [_jsxs("span", { className: "ot-fact-label", children: [labels.sources, ":"] }), " ", details.citations.map(citationLine).join('; ')] }))] }));
|
|
306
|
+
}
|
|
307
|
+
const viewChildren = (dataset, view, id) => (view.target.kind === 'classification' ? dataset.classification(view.target.id) : dataset.phylogeny(view.target.id)).children.get(id) ?? [];
|
|
308
|
+
function HighlightList({ highlights, labels, onRemove }) {
|
|
309
|
+
if (!highlights.length)
|
|
310
|
+
return null;
|
|
311
|
+
return (_jsx("ul", { className: "ot-highlights", children: highlights.map((h) => (_jsxs("li", { "data-kind": h.kind, children: [_jsx("span", { className: "ot-swatch", style: h.color ? { background: h.color } : undefined, "aria-hidden": "true" }), h.label, " \u00B7 ", h.kind === 'clade' ? labels.clade : labels.group, h.assessment?.hypothesis && (_jsxs("span", { className: "ot-assessment", children: [' ', "(", h.assessment.status, ", ", h.assessment.hypothesis.id, " v", h.assessment.hypothesis.version, ")"] })), onRemove && (_jsx("button", { type: "button", className: "ot-remove", onClick: () => onRemove(h.id), "aria-label": `${labels.remove ?? 'Remove'} ${h.label}`, title: labels.remove, children: _jsx(Icon, { name: "close", size: 12 }) }))] }, h.id))) }));
|
|
312
|
+
}
|
|
313
|
+
/** What the drawing is (classification vs phylogeny) and what each highlight is. */
|
|
314
|
+
export function Legend({ layout, highlights, labels: custom, trait, support, onRemoveHighlight, }) {
|
|
315
|
+
const labels = { clade: LABELS.clade, group: LABELS.group, noStatedValue: LABELS.noStatedValue, weakSupport: LABELS.weakSupport, mixed: LABELS.mixed, remove: LABELS.remove, ...custom };
|
|
316
|
+
return (_jsxs("div", { className: "ot-legend", children: [_jsx("p", { className: "ot-notice", children: layout.notice }), _jsx(HighlightList, { highlights: highlights, labels: labels, ...(onRemoveHighlight ? { onRemove: onRemoveHighlight } : {}) }), trait && (_jsxs("ul", { className: "ot-legend-traits", "aria-label": trait.title, children: [trait.states.map((s) => (_jsxs("li", { children: [_jsx("span", { className: "ot-swatch", "data-trait": s.id, style: s.color ? { background: s.color } : undefined, "aria-hidden": "true" }), s.label] }, s.id))), _jsxs("li", { children: [_jsx("span", { className: "ot-swatch", "data-trait": "mixed", "aria-hidden": "true" }), labels.mixed] }), _jsxs("li", { children: [_jsx("span", { className: "ot-swatch", "data-trait": "none", "aria-hidden": "true" }), labels.noStatedValue] })] })), support && _jsx("p", { className: "ot-notice", children: labels.weakSupport })] }));
|
|
317
|
+
}
|
|
@@ -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,50 @@
|
|
|
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
|
+
/** Nodes are focusable and clickable. */
|
|
26
|
+
interactive?: boolean;
|
|
27
|
+
onNodeClick?: (id: Id, event: MouseEvent<SVGGElement>) => void;
|
|
28
|
+
onNodeKeyDown?: (id: Id, event: KeyboardEvent<SVGGElement>) => void;
|
|
29
|
+
/** Word for folded descendants, e.g. "+12 hidden". */
|
|
30
|
+
hiddenLabel?: (count: number) => string;
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
export type ViewBox = {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
/** The box that shows the whole drawing with room for labels (the "fit" view). */
|
|
40
|
+
export declare function treeViewBox(layout: TreeLayout, { padding, labelWidth }?: {
|
|
41
|
+
padding?: number;
|
|
42
|
+
labelWidth?: number;
|
|
43
|
+
}): ViewBox;
|
|
44
|
+
/**
|
|
45
|
+
* The tree as SVG. Renders on the server (no effects, no measuring) and in the
|
|
46
|
+
* browser. Classes are stable (`ot-*`) and states are data attributes, so it can
|
|
47
|
+
* be styled with the bundled stylesheet or entirely your own. The legend notice
|
|
48
|
+
* (classification vs phylogeny) is part of the accessible description.
|
|
49
|
+
*/
|
|
50
|
+
export declare function TreeView(props: TreeViewProps): import("react").JSX.Element;
|
package/dist/TreeView.js
ADDED
|
@@ -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-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 }), 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,18 @@
|
|
|
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>;
|
package/dist/drag.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
readonly check: "M5 12.5 9.5 17 19 7";
|
|
29
|
+
readonly dagger: "M12 3v18M7 8h10";
|
|
30
|
+
readonly image: "M4 5h16v14H4zM4 16l5-5 4 4 3-3 4 4M15.5 9h.01";
|
|
31
|
+
readonly file: "M7 3h7l5 5v13H7zM14 3v5h5";
|
|
32
|
+
readonly book: "M5 5.5A2.5 2.5 0 0 1 7.5 3H19v15H7.5A2.5 2.5 0 0 0 5 20.5zM5 20.5A2.5 2.5 0 0 0 7.5 23H19v-5";
|
|
33
|
+
};
|
|
34
|
+
export type IconName = keyof typeof ICON_PATHS;
|
|
35
|
+
export declare function Icon({ name, size, className, ...rest }: {
|
|
36
|
+
name: IconName;
|
|
37
|
+
size?: number;
|
|
38
|
+
} & Omit<SVGProps<SVGSVGElement>, 'name'>): import("react").JSX.Element;
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
check: 'M5 12.5 9.5 17 19 7',
|
|
29
|
+
dagger: 'M12 3v18M7 8h10',
|
|
30
|
+
image: 'M4 5h16v14H4zM4 16l5-5 4 4 3-3 4 4M15.5 9h.01',
|
|
31
|
+
file: 'M7 3h7l5 5v13H7zM14 3v5h5',
|
|
32
|
+
book: 'M5 5.5A2.5 2.5 0 0 1 7.5 3H19v15H7.5A2.5 2.5 0 0 0 5 20.5zM5 20.5A2.5 2.5 0 0 0 7.5 23H19v-5',
|
|
33
|
+
};
|
|
34
|
+
export function Icon({ name, size = 16, className, ...rest }) {
|
|
35
|
+
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: ["ot-icon", className].filter(Boolean).join(" "), ...rest, children: _jsx("path", { d: ICON_PATHS[name] }) }));
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { TreeView, type TreeViewProps } from './TreeView.js';
|
|
2
|
+
export { TreeOutline, type TreeOutlineProps } from './TreeOutline.js';
|
|
3
|
+
export { Explorer, Legend, layoutFor, useTaxonomyView, type ExplorerProps, type ExplorerLabels, type UseTaxonomyView } from './Explorer.js';
|
|
4
|
+
export { treeViewBox, type ViewBox } from './TreeView.js';
|
|
5
|
+
export { dropOrder, drawnSubtree } from './drag.js';
|
|
6
|
+
export { Icon, ICON_PATHS, type IconName } from './icons.js';
|
|
7
|
+
export { citationLine, disclosureLines, downloadBlob, standaloneSvg, svgToPng } from './export.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { TreeView } from './TreeView.js';
|
|
2
|
+
export { TreeOutline } from './TreeOutline.js';
|
|
3
|
+
export { Explorer, Legend, layoutFor, useTaxonomyView } from './Explorer.js';
|
|
4
|
+
export { treeViewBox } from './TreeView.js';
|
|
5
|
+
export { dropOrder, drawnSubtree } from './drag.js';
|
|
6
|
+
export { Icon, ICON_PATHS } from './icons.js';
|
|
7
|
+
export { citationLine, disclosureLines, downloadBlob, standaloneSvg, svgToPng } from './export.js';
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* OneTaxonomy default styles. Optional: skip this file and style the stable
|
|
3
|
+
* .ot-* classes and data-* attributes yourself, or override the custom properties.
|
|
4
|
+
*/
|
|
5
|
+
.ot-root,
|
|
6
|
+
.ot-explorer {
|
|
7
|
+
--ot-font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
8
|
+
/* Brand family tokens (brand/README.md): Captive Origins brown ink, orange for selection and focus. */
|
|
9
|
+
--ot-ink: #4c312c;
|
|
10
|
+
--ot-text: #1d2330;
|
|
11
|
+
--ot-muted: #5b6577;
|
|
12
|
+
--ot-edge: #a08a83;
|
|
13
|
+
--ot-edge-width: 1.5px;
|
|
14
|
+
--ot-node: var(--ot-ink);
|
|
15
|
+
--ot-selected: #cf8137;
|
|
16
|
+
--ot-surface: #ffffff;
|
|
17
|
+
--ot-panel: #fbf7f4;
|
|
18
|
+
--ot-border: #eaddd6;
|
|
19
|
+
--ot-focus: #cf8137;
|
|
20
|
+
font-family: var(--ot-font);
|
|
21
|
+
color: var(--ot-text);
|
|
22
|
+
}
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
.ot-root,
|
|
25
|
+
.ot-explorer {
|
|
26
|
+
--ot-ink: #eda9ac;
|
|
27
|
+
--ot-text: #eef1f6;
|
|
28
|
+
--ot-muted: #a7b0c0;
|
|
29
|
+
--ot-edge: #8a7470;
|
|
30
|
+
--ot-node: var(--ot-ink);
|
|
31
|
+
--ot-selected: #e7a066;
|
|
32
|
+
--ot-surface: #1c1311;
|
|
33
|
+
--ot-panel: #241916;
|
|
34
|
+
--ot-border: #3a2a26;
|
|
35
|
+
--ot-focus: #e7a066;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
.ot-root { width: 100%; height: 100%; overflow: visible; }
|
|
39
|
+
.ot-edge { fill: none; stroke: var(--ot-edge); stroke-width: var(--ot-edge-width); }
|
|
40
|
+
.ot-node-dot, .ot-node-collapsed { fill: var(--ot-node); }
|
|
41
|
+
.ot-node-halo { opacity: 0.35; }
|
|
42
|
+
.ot-label { fill: var(--ot-text); font-size: 12px; }
|
|
43
|
+
.ot-label[data-italic] { font-style: italic; }
|
|
44
|
+
.ot-node[data-status='extinct'] .ot-node-dot { fill: none; stroke: var(--ot-node); }
|
|
45
|
+
.ot-node[data-selected] .ot-label { fill: var(--ot-selected); font-weight: 600; }
|
|
46
|
+
.ot-node[role='button'] { cursor: pointer; }
|
|
47
|
+
.ot-node:focus { outline: none; }
|
|
48
|
+
.ot-node:focus-visible { outline: 2px solid var(--ot-focus); }
|
|
49
|
+
.ot-canvas { user-select: none; -webkit-user-select: none; }
|
|
50
|
+
.ot-axis-line, .ot-axis-tick { stroke: var(--ot-muted); }
|
|
51
|
+
/* Overlays (M5): only stated values are drawn. */
|
|
52
|
+
.ot-trait { fill: var(--ot-muted); opacity: 0.85; }
|
|
53
|
+
.ot-node[data-trait='mixed'] .ot-trait { fill: none; stroke: var(--ot-muted); stroke-dasharray: 2 2; }
|
|
54
|
+
.ot-support { fill: var(--ot-muted); font-size: 10px; }
|
|
55
|
+
.ot-support[data-weak] { fill: #b45309; }
|
|
56
|
+
.ot-edge[data-weak] { stroke-dasharray: 5 4; }
|
|
57
|
+
.ot-axis-label, .ot-axis-unit { fill: var(--ot-muted); font-size: 11px; }
|
|
58
|
+
|
|
59
|
+
.ot-icon { flex: none; }
|
|
60
|
+
/* Controls: 32 px, radius 8, 1 px border (shared with OnePedigree). */
|
|
61
|
+
.ot-toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 8px; }
|
|
62
|
+
.ot-toolbar-gap { width: 6px; }
|
|
63
|
+
.ot-explorer button, .ot-select {
|
|
64
|
+
display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 10px; border-radius: 8px;
|
|
65
|
+
border: 1px solid var(--ot-border); background: var(--ot-surface); color: inherit; font: inherit; font-size: 13px; cursor: pointer;
|
|
66
|
+
}
|
|
67
|
+
.ot-select select { font: inherit; border: 0; background: transparent; color: inherit; cursor: pointer; }
|
|
68
|
+
.ot-explorer button:hover:not(:disabled), .ot-select:hover { border-color: var(--ot-ink); }
|
|
69
|
+
.ot-explorer button:disabled { opacity: 0.45; cursor: default; }
|
|
70
|
+
.ot-explorer button:focus-visible, .ot-select:focus-within, .ot-canvas:focus-visible { outline: 2px solid var(--ot-focus); outline-offset: 2px; }
|
|
71
|
+
.ot-panes { display: grid; grid-template-columns: minmax(0, 2fr) minmax(15rem, 1fr); gap: 12px; }
|
|
72
|
+
@media (max-width: 48rem) { .ot-panes { grid-template-columns: 1fr; } }
|
|
73
|
+
.ot-canvas { position: relative; overflow: hidden; touch-action: none; border: 1px solid var(--ot-border); border-radius: 12px; background: var(--ot-surface); cursor: grab; }
|
|
74
|
+
.ot-canvas:active { cursor: grabbing; }
|
|
75
|
+
.ot-canvas > svg { display: block; width: 100%; height: 100%; }
|
|
76
|
+
.ot-floating { position: absolute; top: 8px; right: 8px; z-index: 1; display: flex; gap: 4px; }
|
|
77
|
+
.ot-floating button { width: 32px; padding: 0; justify-content: center; }
|
|
78
|
+
.ot-node[data-dragging] { cursor: grabbing; }
|
|
79
|
+
.ot-node[data-dragging] .ot-label { fill: var(--ot-selected); }
|
|
80
|
+
.ot-edge[data-dragging] { stroke-dasharray: 4 3; }
|
|
81
|
+
.ot-canvas[data-dragging] .ot-edge:not([data-dragging]) { opacity: 0.5; }
|
|
82
|
+
.ot-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
|
83
|
+
.ot-outline, .ot-inspector { border: 1px solid var(--ot-border); border-radius: 10px; background: var(--ot-panel); padding: 10px 12px; }
|
|
84
|
+
.ot-outline, .ot-outline ul { list-style: none; margin: 0; padding-inline-start: 1rem; }
|
|
85
|
+
.ot-outline { max-height: 20rem; overflow: auto; font-size: 14px; }
|
|
86
|
+
.ot-outline-item:focus { outline: none; }
|
|
87
|
+
.ot-outline-item:focus-visible > .ot-outline-row { outline: 2px solid var(--ot-focus); border-radius: 0.25rem; }
|
|
88
|
+
.ot-outline-item[aria-selected='true'] > .ot-outline-row { font-weight: 600; color: var(--ot-selected); }
|
|
89
|
+
.ot-outline-row { display: inline-flex; gap: 0.25rem; align-items: baseline; cursor: pointer; }
|
|
90
|
+
.ot-outline-label[data-unnamed] { color: var(--ot-muted); font-style: italic; }
|
|
91
|
+
.ot-outline-twisty { display: inline-block; width: 1rem; text-align: center; }
|
|
92
|
+
.ot-outline-hidden, .ot-assessment, .ot-notice { color: var(--ot-muted); font-size: 0.875em; }
|
|
93
|
+
.ot-inspector { font-size: 14px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.06); }
|
|
94
|
+
.ot-inspector-title { margin: 0; font-size: 15px; }
|
|
95
|
+
.ot-hint { display: flex; gap: 8px; margin: 0; color: var(--ot-muted); }
|
|
96
|
+
.ot-inspector dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.75rem; margin: 0.5rem 0; }
|
|
97
|
+
.ot-inspector dt { color: var(--ot-muted); }
|
|
98
|
+
.ot-inspector dd { margin: 0; }
|
|
99
|
+
.ot-inspector-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
100
|
+
.ot-highlights { list-style: none; padding: 0; margin: 0.5rem 0; }
|
|
101
|
+
.ot-swatch { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 50%; margin-inline-end: 0.375rem; background: var(--ot-muted); }
|
|
102
|
+
.ot-error { color: #b91c1c; }
|
|
103
|
+
.ot-explorer button[aria-pressed='true'] { border-color: var(--ot-selected); background: color-mix(in srgb, var(--ot-selected) 14%, var(--ot-surface)); }
|
|
104
|
+
.ot-menu { position: relative; }
|
|
105
|
+
.ot-menu-list { position: absolute; top: 36px; right: 0; display: flex; flex-direction: column; gap: 2px; padding: 4px; min-width: 12rem; border: 1px solid var(--ot-border); border-radius: 10px; background: var(--ot-surface); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); }
|
|
106
|
+
.ot-floating .ot-menu-list button { width: auto; justify-content: flex-start; padding: 0 10px; border-color: transparent; }
|
|
107
|
+
.ot-facts { font-size: 13px; }
|
|
108
|
+
.ot-facts p { margin: 0.375rem 0; }
|
|
109
|
+
.ot-fact-label { color: var(--ot-muted); }
|
|
110
|
+
.ot-citations { color: var(--ot-muted); overflow-wrap: anywhere; }
|
|
111
|
+
.ot-legend { margin-top: 8px; }
|
|
112
|
+
.ot-legend-traits { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 14px; padding: 0; margin: 6px 0; font-size: 13px; }
|
|
113
|
+
.ot-swatch[data-trait='mixed'] { background: transparent; border: 1px dashed var(--ot-muted); }
|
|
114
|
+
.ot-swatch[data-trait='none'] { background: transparent; border: 1px solid var(--ot-border); }
|
|
115
|
+
.ot-about { margin-top: 8px; font-size: 13px; color: var(--ot-muted); }
|
|
116
|
+
.ot-about summary { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; color: var(--ot-text); }
|
|
117
|
+
.ot-about h4 { margin: 8px 0 4px; font-size: 13px; color: var(--ot-text); }
|
|
118
|
+
.ot-about ul { margin: 4px 0; padding-inline-start: 1.25rem; overflow-wrap: anywhere; }
|
|
119
|
+
.ot-print-disclosure { display: none; }
|
|
120
|
+
.ot-clade-check { display: flex; flex-direction: column; gap: 8px; }
|
|
121
|
+
.ot-selection { margin: 0; font-size: 13px; }
|
|
122
|
+
.ot-assessment-box { border-inline-start: 3px solid var(--ot-muted); padding: 4px 10px; background: var(--ot-surface); border-radius: 6px; }
|
|
123
|
+
.ot-assessment-box p { margin: 4px 0; }
|
|
124
|
+
.ot-clade-check[data-status='monophyletic'] .ot-assessment-box { border-color: #2f7d5c; }
|
|
125
|
+
.ot-clade-check[data-status='not-monophyletic'] .ot-assessment-box { border-color: #b45309; }
|
|
126
|
+
.ot-select input { font: inherit; border: 0; background: transparent; color: inherit; min-width: 6rem; }
|
|
127
|
+
.ot-explorer .ot-remove { height: 20px; width: 20px; padding: 0; justify-content: center; margin-inline-start: 6px; vertical-align: middle; }
|
|
128
|
+
@media print {
|
|
129
|
+
html[data-ot-printing] body * { visibility: hidden; }
|
|
130
|
+
html[data-ot-printing] [data-ot-printing], html[data-ot-printing] [data-ot-printing] * { visibility: visible; }
|
|
131
|
+
html[data-ot-printing] [data-ot-printing] { position: fixed; inset: 0; height: 100% !important; border: 0; border-radius: 0; }
|
|
132
|
+
.ot-floating { display: none; }
|
|
133
|
+
html[data-ot-printing] [data-ot-printing] { display: flex; flex-direction: column; }
|
|
134
|
+
html[data-ot-printing] [data-ot-printing] > svg { flex: 1; min-height: 0; }
|
|
135
|
+
.ot-print-disclosure { display: block; padding: 8px 12px; font-size: 9pt; color: #4c312c; }
|
|
136
|
+
.ot-print-disclosure p { margin: 0 0 2px; }
|
|
137
|
+
}
|
|
138
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
139
|
+
.ot-node:not([data-dragging]) { transition: transform 150ms ease; }
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@captiveorigins/onetaxonomy-react",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "OneTaxonomy for React: a server-renderable SVG tree, an accessible keyboard tree outline, and an explorer with undo/redo, filters, layouts and an inspector that keeps clades and groups apart.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"taxonomy",
|
|
7
|
+
"phylogeny",
|
|
8
|
+
"react",
|
|
9
|
+
"svg",
|
|
10
|
+
"tree",
|
|
11
|
+
"accessibility",
|
|
12
|
+
"education"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/mgilardi/onetaxonomy#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/mgilardi/onetaxonomy/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/mgilardi/onetaxonomy.git",
|
|
22
|
+
"directory": "packages/react"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": [
|
|
26
|
+
"*.css"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./styles.css": "./dist/styles.css"
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@captiveorigins/onetaxonomy-core": "^0.1.0-alpha.0",
|
|
47
|
+
"@captiveorigins/onetaxonomy-layout": "^0.1.0-alpha.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"react": ">=18.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@testing-library/react": "^16.3.3",
|
|
54
|
+
"@types/react": "^19.3.0",
|
|
55
|
+
"@types/react-dom": "^19.3.0",
|
|
56
|
+
"jsdom": "^30.1.3",
|
|
57
|
+
"react": "^19.3.1",
|
|
58
|
+
"react-dom": "^19.3.1"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/styles.css dist/styles.css",
|
|
62
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
63
|
+
"test": "vitest run"
|
|
64
|
+
}
|
|
6
65
|
}
|