hpo-react-visualizer 0.0.1 → 0.0.3
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/dist/index.cjs +619 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -6
- package/dist/index.d.ts +34 -6
- package/dist/index.js +617 -73
- package/dist/index.js.map +1 -1
- package/package.json +17 -17
- package/src/HpoVisualizer.tsx +164 -32
- package/src/OrganSvg.tsx +13 -17
- package/src/ZoomControls.tsx +125 -0
- package/src/__tests__/hpoLabel.test.ts +71 -0
- package/src/__tests__/hpoVisualizerSizing.test.tsx +22 -0
- package/src/__tests__/organControlState.test.ts +26 -0
- package/src/__tests__/renderOrder.test.tsx +24 -37
- package/src/__tests__/transitionStyle.test.ts +11 -0
- package/src/__tests__/useOrganInteraction.test.ts +31 -0
- package/src/__tests__/useZoom.test.ts +144 -0
- package/src/__tests__/zoomControls.test.tsx +106 -0
- package/src/constants.ts +104 -2
- package/src/index.ts +5 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/organControlState.ts +21 -0
- package/src/svg/Neoplasm.tsx +3 -0
- package/src/types.ts +35 -0
- package/src/useOrganInteraction.ts +2 -9
- package/src/useZoom.ts +347 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hpo-react-visualizer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Interactive Human Phenotype Ontology (HPO) organ visualization library",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -30,23 +30,11 @@
|
|
|
30
30
|
"dist",
|
|
31
31
|
"src"
|
|
32
32
|
],
|
|
33
|
-
"scripts": {
|
|
34
|
-
"build": "tsup",
|
|
35
|
-
"dev": "tsup --watch",
|
|
36
|
-
"lint": "biome check .",
|
|
37
|
-
"lint:fix": "biome check --write .",
|
|
38
|
-
"generate:component": "turbo gen react-component",
|
|
39
|
-
"check-types": "tsc --noEmit",
|
|
40
|
-
"clean": "rm -rf dist",
|
|
41
|
-
"test": "vitest run",
|
|
42
|
-
"test:watch": "vitest"
|
|
43
|
-
},
|
|
44
33
|
"peerDependencies": {
|
|
45
|
-
"react": "^19.0.0",
|
|
46
|
-
"react-dom": "^19.0.0"
|
|
34
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
35
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
47
36
|
},
|
|
48
37
|
"devDependencies": {
|
|
49
|
-
"@repo/typescript-config": "workspace:*",
|
|
50
38
|
"@testing-library/jest-dom": "^6.9.1",
|
|
51
39
|
"@testing-library/react": "^16.3.0",
|
|
52
40
|
"@types/node": "^22.15.3",
|
|
@@ -55,6 +43,18 @@
|
|
|
55
43
|
"jsdom": "^27.3.0",
|
|
56
44
|
"tsup": "^8.3.5",
|
|
57
45
|
"typescript": "5.9.2",
|
|
58
|
-
"vitest": "^4.0.15"
|
|
46
|
+
"vitest": "^4.0.15",
|
|
47
|
+
"@repo/typescript-config": "0.0.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"lint": "biome check .",
|
|
53
|
+
"lint:fix": "biome check --write .",
|
|
54
|
+
"generate:component": "turbo gen react-component",
|
|
55
|
+
"check-types": "tsc --noEmit",
|
|
56
|
+
"clean": "rm -rf dist",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest"
|
|
59
59
|
}
|
|
60
|
-
}
|
|
60
|
+
}
|
package/src/HpoVisualizer.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { type CSSProperties, useId, useMemo } from "react";
|
|
1
|
+
import { type CSSProperties, useId, useMemo, useState } from "react";
|
|
2
2
|
import { BODY_VIEWBOX, DEFAULT_COLOR_NAME, ORGAN_IDS } from "./constants";
|
|
3
3
|
import { createStrictColorPalette } from "./lib";
|
|
4
4
|
import { OrganSvg } from "./OrganSvg";
|
|
5
5
|
import { Body } from "./svg/Body";
|
|
6
6
|
import type { HpoVisualizerProps, OrganConfig, OrganId, StrictColorPalette } from "./types";
|
|
7
7
|
import { useOrganInteraction } from "./useOrganInteraction";
|
|
8
|
+
import { useZoom } from "./useZoom";
|
|
9
|
+
import { ZoomControls } from "./ZoomControls";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Default positions and viewBox dimensions for each organ in the visualizer
|
|
@@ -66,6 +68,10 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
|
|
|
66
68
|
"blood",
|
|
67
69
|
] as const;
|
|
68
70
|
|
|
71
|
+
type RenderEntry = { type: "organ"; organId: OrganId } | { type: "body" };
|
|
72
|
+
|
|
73
|
+
const MIN_ZOOM = 1;
|
|
74
|
+
|
|
69
75
|
/**
|
|
70
76
|
* HPO Visualizer - Interactive human organ visualization component
|
|
71
77
|
*
|
|
@@ -75,6 +81,7 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
|
|
|
75
81
|
* - Click to select/deselect organs
|
|
76
82
|
* - Hover effects work independently even when an organ is selected
|
|
77
83
|
* - Exposes hover and selection state via callbacks
|
|
84
|
+
* - Zoom and pan support with mouse wheel and drag
|
|
78
85
|
*/
|
|
79
86
|
export function HpoVisualizer({
|
|
80
87
|
organs,
|
|
@@ -84,12 +91,30 @@ export function HpoVisualizer({
|
|
|
84
91
|
onHover,
|
|
85
92
|
onSelect,
|
|
86
93
|
colorPalette: inputColorPalette,
|
|
87
|
-
width =
|
|
88
|
-
height =
|
|
94
|
+
width = "100%",
|
|
95
|
+
height = "100%",
|
|
89
96
|
className,
|
|
90
97
|
style,
|
|
98
|
+
maxZoom = 5,
|
|
99
|
+
wheelZoom = true,
|
|
91
100
|
}: HpoVisualizerProps) {
|
|
92
101
|
const visualizerID = useId();
|
|
102
|
+
const [isHovering, setIsHovering] = useState(false);
|
|
103
|
+
|
|
104
|
+
// Zoom and pan functionality
|
|
105
|
+
const {
|
|
106
|
+
zoom,
|
|
107
|
+
pan,
|
|
108
|
+
zoomIn,
|
|
109
|
+
zoomOut,
|
|
110
|
+
resetZoom,
|
|
111
|
+
handleMouseDown,
|
|
112
|
+
handleMouseMove,
|
|
113
|
+
handleMouseUp,
|
|
114
|
+
isDragging,
|
|
115
|
+
isDefaultZoom,
|
|
116
|
+
containerRef,
|
|
117
|
+
} = useZoom({ minZoom: MIN_ZOOM, maxZoom, wheelZoom, viewBox: BODY_VIEWBOX });
|
|
93
118
|
|
|
94
119
|
// Create strict color palette with all required colors and alpha values
|
|
95
120
|
const colorPalette: StrictColorPalette = useMemo(
|
|
@@ -124,13 +149,54 @@ export function HpoVisualizer({
|
|
|
124
149
|
}, [organs]);
|
|
125
150
|
|
|
126
151
|
// Use the interaction hook
|
|
127
|
-
const { handlers, isHovered, isSelected } = useOrganInteraction({
|
|
152
|
+
const { handlers, isHovered, isSelected, state } = useOrganInteraction({
|
|
128
153
|
hoveredOrgan: controlledHovered,
|
|
129
154
|
selectedOrgan: controlledSelected,
|
|
130
155
|
onHover,
|
|
131
156
|
onSelect,
|
|
132
157
|
});
|
|
133
158
|
|
|
159
|
+
const selectedOrganId = state.selectedOrgan;
|
|
160
|
+
const renderEntries = useMemo(() => {
|
|
161
|
+
const base: RenderEntry[] = [
|
|
162
|
+
...BACKGROUND_ORGANS.map((organId): RenderEntry => ({ type: "organ", organId })),
|
|
163
|
+
{ type: "body" },
|
|
164
|
+
...FOREGROUND_ORGANS.map((organId): RenderEntry => ({ type: "organ", organId })),
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (!selectedOrganId) {
|
|
168
|
+
return base;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const selectedIndex = base.findIndex(
|
|
172
|
+
(entry) => entry.type === "organ" && entry.organId === selectedOrganId,
|
|
173
|
+
);
|
|
174
|
+
if (selectedIndex === -1) {
|
|
175
|
+
return base;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const selectedEntry = base[selectedIndex];
|
|
179
|
+
if (!selectedEntry || selectedEntry.type !== "organ") {
|
|
180
|
+
return base;
|
|
181
|
+
}
|
|
182
|
+
return [...base.slice(0, selectedIndex), ...base.slice(selectedIndex + 1), selectedEntry];
|
|
183
|
+
}, [selectedOrganId]);
|
|
184
|
+
|
|
185
|
+
const {
|
|
186
|
+
padding,
|
|
187
|
+
paddingTop,
|
|
188
|
+
paddingRight,
|
|
189
|
+
paddingBottom,
|
|
190
|
+
paddingLeft,
|
|
191
|
+
paddingInline,
|
|
192
|
+
paddingInlineStart,
|
|
193
|
+
paddingInlineEnd,
|
|
194
|
+
paddingBlock,
|
|
195
|
+
paddingBlockStart,
|
|
196
|
+
paddingBlockEnd,
|
|
197
|
+
...containerStyleOverrides
|
|
198
|
+
} = style ?? {};
|
|
199
|
+
|
|
134
200
|
const containerStyle: CSSProperties = {
|
|
135
201
|
display: "flex",
|
|
136
202
|
justifyContent: "center",
|
|
@@ -139,12 +205,43 @@ export function HpoVisualizer({
|
|
|
139
205
|
position: "relative",
|
|
140
206
|
width: width,
|
|
141
207
|
height: height,
|
|
142
|
-
...
|
|
208
|
+
...containerStyleOverrides,
|
|
209
|
+
// Apply overflow after style spread to ensure it takes precedence
|
|
210
|
+
overflow: "clip",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const contentStyle: CSSProperties = {
|
|
214
|
+
display: "flex",
|
|
215
|
+
justifyContent: "center",
|
|
216
|
+
alignItems: "flex-end",
|
|
217
|
+
width: "100%",
|
|
218
|
+
height: "100%",
|
|
219
|
+
boxSizing: "border-box",
|
|
220
|
+
padding,
|
|
221
|
+
paddingTop,
|
|
222
|
+
paddingRight,
|
|
223
|
+
paddingBottom,
|
|
224
|
+
paddingLeft,
|
|
225
|
+
paddingInline,
|
|
226
|
+
paddingInlineStart,
|
|
227
|
+
paddingInlineEnd,
|
|
228
|
+
paddingBlock,
|
|
229
|
+
paddingBlockStart,
|
|
230
|
+
paddingBlockEnd,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const svgStyle: CSSProperties = {
|
|
234
|
+
width: "100%",
|
|
235
|
+
height: "100%",
|
|
236
|
+
display: "block",
|
|
237
|
+
cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
|
|
238
|
+
overflow: "visible",
|
|
143
239
|
};
|
|
144
240
|
|
|
145
241
|
const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
|
|
146
|
-
const
|
|
147
|
-
const
|
|
242
|
+
const centerX = BODY_VIEWBOX.width / 2;
|
|
243
|
+
const centerY = BODY_VIEWBOX.height / 2;
|
|
244
|
+
const zoomTransform = `translate(${centerX} ${centerY}) scale(${zoom}) translate(${-centerX} ${-centerY})`;
|
|
148
245
|
|
|
149
246
|
// Helper function to render an organ
|
|
150
247
|
const renderOrgan = (organId: OrganId, isVisible: boolean) => {
|
|
@@ -156,10 +253,10 @@ export function HpoVisualizer({
|
|
|
156
253
|
return null;
|
|
157
254
|
}
|
|
158
255
|
|
|
159
|
-
const x =
|
|
160
|
-
const y = position.y
|
|
161
|
-
const width = position.width
|
|
162
|
-
const height = position.height
|
|
256
|
+
const x = position.x;
|
|
257
|
+
const y = position.y;
|
|
258
|
+
const width = position.width;
|
|
259
|
+
const height = position.height;
|
|
163
260
|
|
|
164
261
|
return (
|
|
165
262
|
<OrganSvg
|
|
@@ -184,27 +281,62 @@ export function HpoVisualizer({
|
|
|
184
281
|
};
|
|
185
282
|
|
|
186
283
|
return (
|
|
187
|
-
<div
|
|
188
|
-
{
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
284
|
+
<div
|
|
285
|
+
className={className}
|
|
286
|
+
style={containerStyle}
|
|
287
|
+
onMouseEnter={() => setIsHovering(true)}
|
|
288
|
+
onMouseLeave={() => {
|
|
289
|
+
setIsHovering(false);
|
|
290
|
+
handleMouseUp();
|
|
291
|
+
}}
|
|
292
|
+
onMouseDown={handleMouseDown}
|
|
293
|
+
onMouseMove={handleMouseMove}
|
|
294
|
+
onMouseUp={handleMouseUp}
|
|
295
|
+
role="application"
|
|
296
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: Required for keyboard accessibility in zoom/pan application
|
|
297
|
+
tabIndex={0}
|
|
298
|
+
>
|
|
299
|
+
<div style={contentStyle}>
|
|
300
|
+
<svg
|
|
301
|
+
ref={containerRef}
|
|
302
|
+
width="100%"
|
|
303
|
+
height="100%"
|
|
304
|
+
viewBox={viewBox}
|
|
305
|
+
preserveAspectRatio="xMidYMid meet"
|
|
306
|
+
style={svgStyle}
|
|
307
|
+
aria-label="Human organ visualizer"
|
|
308
|
+
>
|
|
309
|
+
<title>Human organ visualizer</title>
|
|
310
|
+
<g transform={`translate(${pan.x} ${pan.y})`}>
|
|
311
|
+
<g transform={zoomTransform}>
|
|
312
|
+
{renderEntries.map((entry) => {
|
|
313
|
+
if (entry.type === "body") {
|
|
314
|
+
return (
|
|
315
|
+
<Body
|
|
316
|
+
key={`${visualizerID}-body`}
|
|
317
|
+
colorScale={colorPalette[DEFAULT_COLOR_NAME]}
|
|
318
|
+
style={{
|
|
319
|
+
fill: "#fff",
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return renderOrgan(entry.organId, visibleOrganIds.includes(entry.organId));
|
|
325
|
+
})}
|
|
326
|
+
</g>
|
|
327
|
+
</g>
|
|
328
|
+
</svg>
|
|
329
|
+
</div>
|
|
330
|
+
<ZoomControls
|
|
331
|
+
onZoomIn={zoomIn}
|
|
332
|
+
onZoomOut={zoomOut}
|
|
333
|
+
onReset={resetZoom}
|
|
334
|
+
showResetButton={!isDefaultZoom}
|
|
335
|
+
zoom={zoom}
|
|
336
|
+
minZoom={MIN_ZOOM}
|
|
337
|
+
maxZoom={maxZoom}
|
|
338
|
+
isVisible={isHovering}
|
|
339
|
+
/>
|
|
208
340
|
</div>
|
|
209
341
|
);
|
|
210
342
|
}
|
package/src/OrganSvg.tsx
CHANGED
|
@@ -25,13 +25,13 @@ export interface OrganSvgWrapperProps {
|
|
|
25
25
|
onMouseLeave: () => void;
|
|
26
26
|
/** Click handler */
|
|
27
27
|
onClick: () => void;
|
|
28
|
-
/** X position in parent coordinate system */
|
|
28
|
+
/** X position in parent viewBox coordinate system */
|
|
29
29
|
x: number;
|
|
30
|
-
/** Y position in parent coordinate system */
|
|
30
|
+
/** Y position in parent viewBox coordinate system */
|
|
31
31
|
y: number;
|
|
32
|
-
/** Width of the organ SVG viewport */
|
|
32
|
+
/** Width of the organ SVG viewport in viewBox units */
|
|
33
33
|
width: number;
|
|
34
|
-
/** Height of the organ SVG viewport */
|
|
34
|
+
/** Height of the organ SVG viewport in viewBox units */
|
|
35
35
|
height: number;
|
|
36
36
|
/** ViewBox for the organ SVG (local coordinate system) */
|
|
37
37
|
viewBox: string;
|
|
@@ -93,25 +93,21 @@ export function OrganSvg({
|
|
|
93
93
|
return mergeStyles(userStyle, strokeStyle);
|
|
94
94
|
}, [config?.style, showOutline]);
|
|
95
95
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
const [minX = 0, minY = 0, viewBoxWidth, viewBoxHeight] = viewBox.split(" ").map(Number);
|
|
97
|
+
const scaleX = viewBoxWidth ? width / viewBoxWidth : 1;
|
|
98
|
+
const scaleY = viewBoxHeight ? height / viewBoxHeight : 1;
|
|
99
|
+
const transform = `translate(${x} ${y}) scale(${scaleX} ${scaleY}) translate(${-minX} ${-minY})`;
|
|
100
|
+
const groupStyle: CSSProperties = {
|
|
100
101
|
transition: `${TRANSITION_STYLE}, visibility 0s`,
|
|
101
|
-
zIndex: isSelected ? 1 : 0,
|
|
102
102
|
};
|
|
103
103
|
const filter = isActive ? "blur(1px)" : undefined;
|
|
104
104
|
|
|
105
105
|
return (
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
viewBox={viewBox}
|
|
110
|
-
style={svgStyle}
|
|
106
|
+
<g
|
|
107
|
+
transform={transform}
|
|
108
|
+
style={groupStyle}
|
|
111
109
|
filter={filter}
|
|
112
110
|
aria-label={organId}
|
|
113
|
-
overflow="visible"
|
|
114
|
-
pointerEvents="none"
|
|
115
111
|
opacity={isVisible ? 1 : 0}
|
|
116
112
|
visibility={isVisible ? "visible" : "hidden"}
|
|
117
113
|
>
|
|
@@ -136,6 +132,6 @@ export function OrganSvg({
|
|
|
136
132
|
colorScale={colorPalette[config?.colorName ?? DEFAULT_COLOR_NAME]}
|
|
137
133
|
/>
|
|
138
134
|
</g>
|
|
139
|
-
</
|
|
135
|
+
</g>
|
|
140
136
|
);
|
|
141
137
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
interface ZoomControlsProps {
|
|
4
|
+
/** Callback when zoom in button is clicked */
|
|
5
|
+
onZoomIn: () => void;
|
|
6
|
+
/** Callback when zoom out button is clicked */
|
|
7
|
+
onZoomOut: () => void;
|
|
8
|
+
/** Callback when reset button is clicked */
|
|
9
|
+
onReset: () => void;
|
|
10
|
+
/** Whether to show the reset button */
|
|
11
|
+
showResetButton: boolean;
|
|
12
|
+
/** Current zoom level (1 = 100%) */
|
|
13
|
+
zoom: number;
|
|
14
|
+
/** Minimum zoom level */
|
|
15
|
+
minZoom: number;
|
|
16
|
+
/** Maximum zoom level */
|
|
17
|
+
maxZoom: number;
|
|
18
|
+
/** Whether the controls are visible (for fade effect) */
|
|
19
|
+
isVisible: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const buttonBaseStyle: CSSProperties = {
|
|
23
|
+
width: 32,
|
|
24
|
+
height: 32,
|
|
25
|
+
display: "flex",
|
|
26
|
+
alignItems: "center",
|
|
27
|
+
justifyContent: "center",
|
|
28
|
+
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
|
29
|
+
border: "1px solid #d1d5db",
|
|
30
|
+
borderRadius: 6,
|
|
31
|
+
cursor: "pointer",
|
|
32
|
+
fontSize: 18,
|
|
33
|
+
fontWeight: "bold",
|
|
34
|
+
color: "#374151",
|
|
35
|
+
transition: "background-color 0.15s ease",
|
|
36
|
+
userSelect: "none",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const buttonDisabledStyle: CSSProperties = {
|
|
40
|
+
...buttonBaseStyle,
|
|
41
|
+
opacity: 0.4,
|
|
42
|
+
cursor: "not-allowed",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getContainerStyle = (isVisible: boolean): CSSProperties => ({
|
|
46
|
+
position: "absolute",
|
|
47
|
+
bottom: 12,
|
|
48
|
+
right: 12,
|
|
49
|
+
display: "flex",
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
gap: 4,
|
|
52
|
+
zIndex: 10,
|
|
53
|
+
opacity: isVisible ? 1 : 0,
|
|
54
|
+
transition: "opacity 0.2s ease-in-out",
|
|
55
|
+
pointerEvents: isVisible ? "auto" : "none",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const getResetContainerStyle = (isVisible: boolean): CSSProperties => ({
|
|
59
|
+
position: "absolute",
|
|
60
|
+
bottom: 12,
|
|
61
|
+
left: 12,
|
|
62
|
+
zIndex: 10,
|
|
63
|
+
opacity: isVisible ? 1 : 0,
|
|
64
|
+
transition: "opacity 0.2s ease-in-out",
|
|
65
|
+
pointerEvents: isVisible ? "auto" : "none",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const resetButtonStyle: CSSProperties = {
|
|
69
|
+
...buttonBaseStyle,
|
|
70
|
+
width: "auto",
|
|
71
|
+
padding: "6px 12px",
|
|
72
|
+
fontSize: 12,
|
|
73
|
+
fontWeight: 500,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Zoom control buttons component
|
|
78
|
+
* Displays +/- buttons in bottom right, reset button in bottom left
|
|
79
|
+
*/
|
|
80
|
+
export function ZoomControls({
|
|
81
|
+
onZoomIn,
|
|
82
|
+
onZoomOut,
|
|
83
|
+
onReset,
|
|
84
|
+
showResetButton,
|
|
85
|
+
zoom,
|
|
86
|
+
minZoom,
|
|
87
|
+
maxZoom,
|
|
88
|
+
isVisible,
|
|
89
|
+
}: ZoomControlsProps) {
|
|
90
|
+
const isMinZoom = zoom <= minZoom;
|
|
91
|
+
const isMaxZoom = zoom >= maxZoom;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
{/* Zoom in/out buttons - bottom right */}
|
|
96
|
+
<div style={getContainerStyle(isVisible)}>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={onZoomIn}
|
|
100
|
+
disabled={isMaxZoom}
|
|
101
|
+
style={isMaxZoom ? buttonDisabledStyle : buttonBaseStyle}
|
|
102
|
+
aria-label="Zoom in"
|
|
103
|
+
>
|
|
104
|
+
+
|
|
105
|
+
</button>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={onZoomOut}
|
|
109
|
+
disabled={isMinZoom}
|
|
110
|
+
style={isMinZoom ? buttonDisabledStyle : buttonBaseStyle}
|
|
111
|
+
aria-label="Zoom out"
|
|
112
|
+
>
|
|
113
|
+
−
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Reset button - bottom left (only shown when not at default zoom) */}
|
|
118
|
+
<div style={getResetContainerStyle(isVisible && showResetButton)}>
|
|
119
|
+
<button type="button" onClick={onReset} style={resetButtonStyle} aria-label="Reset zoom">
|
|
120
|
+
↺
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { HPO_LABEL_TO_ORGAN, HPO_LABELS, ORGAN_IDS, ORGAN_TO_HPO_LABEL } from "../constants";
|
|
4
|
+
import type { HPOLabel, OrganId } from "../types";
|
|
5
|
+
|
|
6
|
+
describe("HPO Labels", () => {
|
|
7
|
+
describe("HPO_LABELS", () => {
|
|
8
|
+
it("should contain all 24 HPO labels", () => {
|
|
9
|
+
expect(HPO_LABELS).toHaveLength(24);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should include 'others' label", () => {
|
|
13
|
+
expect(HPO_LABELS).toContain("others");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("HPO_LABEL_TO_ORGAN mapping", () => {
|
|
18
|
+
it("should map all HPO labels except 'others' to OrganId", () => {
|
|
19
|
+
const labelsWithoutOthers = HPO_LABELS.filter((label) => label !== "others");
|
|
20
|
+
expect(Object.keys(HPO_LABEL_TO_ORGAN)).toHaveLength(labelsWithoutOthers.length);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should map each HPO label to a valid OrganId", () => {
|
|
24
|
+
for (const [_label, organId] of Object.entries(HPO_LABEL_TO_ORGAN)) {
|
|
25
|
+
expect(ORGAN_IDS).toContain(organId);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should correctly map specific HPO labels", () => {
|
|
30
|
+
expect(HPO_LABEL_TO_ORGAN["Abnormality of the digestive system"]).toBe("digestive");
|
|
31
|
+
expect(HPO_LABEL_TO_ORGAN["Growth abnormality"]).toBe("growth");
|
|
32
|
+
expect(HPO_LABEL_TO_ORGAN["Abnormality of the eye"]).toBe("eye");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("ORGAN_TO_HPO_LABEL mapping", () => {
|
|
37
|
+
it("should map all OrganIds to HPO labels", () => {
|
|
38
|
+
expect(Object.keys(ORGAN_TO_HPO_LABEL)).toHaveLength(ORGAN_IDS.length);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should map each OrganId to a valid HPO label", () => {
|
|
42
|
+
for (const [_organId, label] of Object.entries(ORGAN_TO_HPO_LABEL)) {
|
|
43
|
+
expect(HPO_LABELS).toContain(label);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should correctly map specific OrganIds", () => {
|
|
48
|
+
expect(ORGAN_TO_HPO_LABEL.digestive).toBe("Abnormality of the digestive system");
|
|
49
|
+
expect(ORGAN_TO_HPO_LABEL.growth).toBe("Growth abnormality");
|
|
50
|
+
expect(ORGAN_TO_HPO_LABEL.eye).toBe("Abnormality of the eye");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("Bidirectional mapping consistency", () => {
|
|
55
|
+
it("should have consistent bidirectional mapping", () => {
|
|
56
|
+
// HPO_LABEL_TO_ORGAN → ORGAN_TO_HPO_LABEL should return original label
|
|
57
|
+
for (const [label, organId] of Object.entries(HPO_LABEL_TO_ORGAN)) {
|
|
58
|
+
const reverseLabel = ORGAN_TO_HPO_LABEL[organId as OrganId];
|
|
59
|
+
expect(reverseLabel).toBe(label);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should have consistent reverse mapping", () => {
|
|
64
|
+
// ORGAN_TO_HPO_LABEL → HPO_LABEL_TO_ORGAN should return original organId
|
|
65
|
+
for (const [organId, label] of Object.entries(ORGAN_TO_HPO_LABEL)) {
|
|
66
|
+
const reverseOrganId = HPO_LABEL_TO_ORGAN[label as Exclude<HPOLabel, "others">];
|
|
67
|
+
expect(reverseOrganId).toBe(organId);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { HpoVisualizer } from "../HpoVisualizer";
|
|
4
|
+
|
|
5
|
+
describe("HpoVisualizer sizing", () => {
|
|
6
|
+
it("should render root svg with percentage sizing by default", () => {
|
|
7
|
+
const { container } = render(<HpoVisualizer />);
|
|
8
|
+
const rootSvg = container.querySelector(
|
|
9
|
+
"svg[aria-label='Human organ visualizer']",
|
|
10
|
+
) as SVGSVGElement;
|
|
11
|
+
expect(rootSvg).toBeDefined();
|
|
12
|
+
expect(rootSvg.getAttribute("width")).toBe("100%");
|
|
13
|
+
expect(rootSvg.getAttribute("height")).toBe("100%");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should apply width/height props to the container", () => {
|
|
17
|
+
const { container } = render(<HpoVisualizer width={300} height={400} />);
|
|
18
|
+
const root = container.firstElementChild as HTMLElement;
|
|
19
|
+
expect(root.style.width).toBe("300px");
|
|
20
|
+
expect(root.style.height).toBe("400px");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ORGAN_IDS } from "../constants";
|
|
4
|
+
import { createOrganOutlineSet, createUniformOrganColorSchemes } from "../lib/organControlState";
|
|
5
|
+
|
|
6
|
+
describe("organ control helpers", () => {
|
|
7
|
+
it("creates uniform color schemes for all organs", () => {
|
|
8
|
+
const schemes = createUniformOrganColorSchemes(ORGAN_IDS, "yellow");
|
|
9
|
+
|
|
10
|
+
for (const organId of ORGAN_IDS) {
|
|
11
|
+
expect(schemes[organId]).toBe("yellow");
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("creates outline sets based on the enabled flag", () => {
|
|
16
|
+
const enabledSet = createOrganOutlineSet(ORGAN_IDS, true);
|
|
17
|
+
const disabledSet = createOrganOutlineSet(ORGAN_IDS, false);
|
|
18
|
+
|
|
19
|
+
expect(enabledSet.size).toBe(ORGAN_IDS.length);
|
|
20
|
+
for (const organId of ORGAN_IDS) {
|
|
21
|
+
expect(enabledSet.has(organId)).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(disabledSet.size).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
});
|