hpo-react-visualizer 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +376 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -2
- package/dist/index.d.ts +23 -2
- package/dist/index.js +376 -30
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/HpoVisualizer.tsx +88 -22
- package/src/ZoomControls.tsx +125 -0
- package/src/__tests__/hpoLabel.test.ts +71 -0
- package/src/__tests__/useZoom.test.ts +110 -0
- package/src/__tests__/zoomControls.test.tsx +106 -0
- package/src/constants.ts +90 -1
- package/src/index.ts +4 -0
- package/src/types.ts +32 -0
- package/src/useZoom.ts +196 -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.2",
|
|
4
4
|
"description": "Interactive Human Phenotype Ontology (HPO) organ visualization library",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"test:watch": "vitest"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"react": "^19.0.0",
|
|
46
|
-
"react-dom": "^19.0.0"
|
|
45
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
46
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@repo/typescript-config": "workspace:*",
|
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,8 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
|
|
|
66
68
|
"blood",
|
|
67
69
|
] as const;
|
|
68
70
|
|
|
71
|
+
const MIN_ZOOM = 1;
|
|
72
|
+
|
|
69
73
|
/**
|
|
70
74
|
* HPO Visualizer - Interactive human organ visualization component
|
|
71
75
|
*
|
|
@@ -75,6 +79,7 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
|
|
|
75
79
|
* - Click to select/deselect organs
|
|
76
80
|
* - Hover effects work independently even when an organ is selected
|
|
77
81
|
* - Exposes hover and selection state via callbacks
|
|
82
|
+
* - Zoom and pan support with mouse wheel and drag
|
|
78
83
|
*/
|
|
79
84
|
export function HpoVisualizer({
|
|
80
85
|
organs,
|
|
@@ -88,8 +93,25 @@ export function HpoVisualizer({
|
|
|
88
93
|
height = BODY_VIEWBOX.height,
|
|
89
94
|
className,
|
|
90
95
|
style,
|
|
96
|
+
maxZoom = 5,
|
|
91
97
|
}: HpoVisualizerProps) {
|
|
92
98
|
const visualizerID = useId();
|
|
99
|
+
const [isHovering, setIsHovering] = useState(false);
|
|
100
|
+
|
|
101
|
+
// Zoom and pan functionality
|
|
102
|
+
const {
|
|
103
|
+
zoom,
|
|
104
|
+
pan,
|
|
105
|
+
zoomIn,
|
|
106
|
+
zoomOut,
|
|
107
|
+
resetZoom,
|
|
108
|
+
handleMouseDown,
|
|
109
|
+
handleMouseMove,
|
|
110
|
+
handleMouseUp,
|
|
111
|
+
isDragging,
|
|
112
|
+
isDefaultZoom,
|
|
113
|
+
containerRef,
|
|
114
|
+
} = useZoom({ minZoom: MIN_ZOOM, maxZoom });
|
|
93
115
|
|
|
94
116
|
// Create strict color palette with all required colors and alpha values
|
|
95
117
|
const colorPalette: StrictColorPalette = useMemo(
|
|
@@ -140,6 +162,19 @@ export function HpoVisualizer({
|
|
|
140
162
|
width: width,
|
|
141
163
|
height: height,
|
|
142
164
|
...style,
|
|
165
|
+
// Apply overflow after style spread to ensure it takes precedence
|
|
166
|
+
// Use 'clip' instead of 'hidden' to respect padding
|
|
167
|
+
overflow: "clip",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const contentStyle: CSSProperties = {
|
|
171
|
+
position: "relative",
|
|
172
|
+
width: "100%",
|
|
173
|
+
height: "100%",
|
|
174
|
+
transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
|
|
175
|
+
transformOrigin: "center center",
|
|
176
|
+
cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
|
|
177
|
+
transition: isDragging ? "none" : "transform 0.1s ease-out",
|
|
143
178
|
};
|
|
144
179
|
|
|
145
180
|
const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
|
|
@@ -184,27 +219,58 @@ export function HpoVisualizer({
|
|
|
184
219
|
};
|
|
185
220
|
|
|
186
221
|
return (
|
|
187
|
-
<div
|
|
188
|
-
{
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
222
|
+
<div
|
|
223
|
+
ref={containerRef}
|
|
224
|
+
className={className}
|
|
225
|
+
style={containerStyle}
|
|
226
|
+
onMouseEnter={() => setIsHovering(true)}
|
|
227
|
+
onMouseLeave={() => {
|
|
228
|
+
setIsHovering(false);
|
|
229
|
+
handleMouseUp();
|
|
230
|
+
}}
|
|
231
|
+
onMouseDown={handleMouseDown}
|
|
232
|
+
onMouseMove={handleMouseMove}
|
|
233
|
+
onMouseUp={handleMouseUp}
|
|
234
|
+
role="application"
|
|
235
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: Required for keyboard accessibility in zoom/pan application
|
|
236
|
+
tabIndex={0}
|
|
237
|
+
>
|
|
238
|
+
<div style={contentStyle}>
|
|
239
|
+
{BACKGROUND_ORGANS.map((organId) =>
|
|
240
|
+
renderOrgan(organId, visibleOrganIds.includes(organId)),
|
|
241
|
+
)}
|
|
242
|
+
{/* Background Body - rendered as nested SVG for flat structure */}
|
|
243
|
+
<svg
|
|
244
|
+
x={translateX}
|
|
245
|
+
y="0"
|
|
246
|
+
width={width}
|
|
247
|
+
height={height}
|
|
248
|
+
viewBox={viewBox}
|
|
249
|
+
style={{ position: "absolute", top: "0", left: "0" }}
|
|
250
|
+
pointerEvents="none"
|
|
251
|
+
>
|
|
252
|
+
<title>Human body</title>
|
|
253
|
+
<Body
|
|
254
|
+
colorScale={colorPalette[DEFAULT_COLOR_NAME]}
|
|
255
|
+
style={{
|
|
256
|
+
fill: "#fff",
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
</svg>
|
|
260
|
+
{FOREGROUND_ORGANS.map((organId) =>
|
|
261
|
+
renderOrgan(organId, visibleOrganIds.includes(organId)),
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
<ZoomControls
|
|
265
|
+
onZoomIn={zoomIn}
|
|
266
|
+
onZoomOut={zoomOut}
|
|
267
|
+
onReset={resetZoom}
|
|
268
|
+
showResetButton={!isDefaultZoom}
|
|
269
|
+
zoom={zoom}
|
|
270
|
+
minZoom={MIN_ZOOM}
|
|
271
|
+
maxZoom={maxZoom}
|
|
272
|
+
isVisible={isHovering}
|
|
273
|
+
/>
|
|
208
274
|
</div>
|
|
209
275
|
);
|
|
210
276
|
}
|
|
@@ -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,110 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { useZoom } from "../useZoom";
|
|
4
|
+
|
|
5
|
+
describe("useZoom hook", () => {
|
|
6
|
+
it("should initialize with default values", () => {
|
|
7
|
+
const { result } = renderHook(() => useZoom());
|
|
8
|
+
|
|
9
|
+
expect(result.current.zoom).toBe(1);
|
|
10
|
+
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
|
11
|
+
expect(result.current.isDefaultZoom).toBe(true);
|
|
12
|
+
expect(result.current.isDragging).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should increase zoom when zoomIn is called", () => {
|
|
16
|
+
const { result } = renderHook(() => useZoom());
|
|
17
|
+
|
|
18
|
+
act(() => {
|
|
19
|
+
result.current.zoomIn();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result.current.zoom).toBe(1.5);
|
|
23
|
+
expect(result.current.isDefaultZoom).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should decrease zoom when zoomOut is called", () => {
|
|
27
|
+
const { result } = renderHook(() => useZoom());
|
|
28
|
+
|
|
29
|
+
// First zoom in to have room to zoom out
|
|
30
|
+
act(() => {
|
|
31
|
+
result.current.zoomIn();
|
|
32
|
+
result.current.zoomIn();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.current.zoom).toBe(2);
|
|
36
|
+
|
|
37
|
+
act(() => {
|
|
38
|
+
result.current.zoomOut();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.current.zoom).toBe(1.5);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should not go below minZoom", () => {
|
|
45
|
+
const { result } = renderHook(() => useZoom({ minZoom: 1 }));
|
|
46
|
+
|
|
47
|
+
act(() => {
|
|
48
|
+
result.current.zoomOut();
|
|
49
|
+
result.current.zoomOut();
|
|
50
|
+
result.current.zoomOut();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result.current.zoom).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should not go above maxZoom", () => {
|
|
57
|
+
const { result } = renderHook(() => useZoom({ maxZoom: 2 }));
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current.zoomIn();
|
|
61
|
+
result.current.zoomIn();
|
|
62
|
+
result.current.zoomIn();
|
|
63
|
+
result.current.zoomIn();
|
|
64
|
+
result.current.zoomIn();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(result.current.zoom).toBe(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should reset zoom and pan when resetZoom is called", () => {
|
|
71
|
+
const { result } = renderHook(() => useZoom());
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.zoomIn();
|
|
75
|
+
result.current.zoomIn();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result.current.zoom).toBe(2);
|
|
79
|
+
|
|
80
|
+
act(() => {
|
|
81
|
+
result.current.resetZoom();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.current.zoom).toBe(1);
|
|
85
|
+
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
|
86
|
+
expect(result.current.isDefaultZoom).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should use custom zoomStep", () => {
|
|
90
|
+
const { result } = renderHook(() => useZoom({ zoomStep: 0.5 }));
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
result.current.zoomIn();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.current.zoom).toBe(1.5);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should return isDefaultZoom as false when zoomed", () => {
|
|
100
|
+
const { result } = renderHook(() => useZoom());
|
|
101
|
+
|
|
102
|
+
expect(result.current.isDefaultZoom).toBe(true);
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
result.current.zoomIn();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.current.isDefaultZoom).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ZoomControls } from "../ZoomControls";
|
|
4
|
+
|
|
5
|
+
describe("ZoomControls component", () => {
|
|
6
|
+
const defaultProps = {
|
|
7
|
+
onZoomIn: vi.fn(),
|
|
8
|
+
onZoomOut: vi.fn(),
|
|
9
|
+
onReset: vi.fn(),
|
|
10
|
+
showResetButton: false,
|
|
11
|
+
zoom: 1,
|
|
12
|
+
minZoom: 1,
|
|
13
|
+
maxZoom: 5,
|
|
14
|
+
isVisible: true,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
it("should render zoom in and zoom out buttons", () => {
|
|
18
|
+
const { getByLabelText } = render(<ZoomControls {...defaultProps} />);
|
|
19
|
+
|
|
20
|
+
expect(getByLabelText("Zoom in")).toBeDefined();
|
|
21
|
+
expect(getByLabelText("Zoom out")).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should call onZoomIn when + button is clicked", () => {
|
|
25
|
+
const onZoomIn = vi.fn();
|
|
26
|
+
const { getByLabelText } = render(<ZoomControls {...defaultProps} onZoomIn={onZoomIn} />);
|
|
27
|
+
|
|
28
|
+
fireEvent.click(getByLabelText("Zoom in"));
|
|
29
|
+
|
|
30
|
+
expect(onZoomIn).toHaveBeenCalledTimes(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should call onZoomOut when - button is clicked", () => {
|
|
34
|
+
const onZoomOut = vi.fn();
|
|
35
|
+
const { getByLabelText } = render(
|
|
36
|
+
<ZoomControls {...defaultProps} zoom={2} onZoomOut={onZoomOut} />,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
fireEvent.click(getByLabelText("Zoom out"));
|
|
40
|
+
|
|
41
|
+
expect(onZoomOut).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should disable zoom in button when at maxZoom", () => {
|
|
45
|
+
const onZoomIn = vi.fn();
|
|
46
|
+
const { getByLabelText } = render(
|
|
47
|
+
<ZoomControls {...defaultProps} zoom={5} maxZoom={5} onZoomIn={onZoomIn} />,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const zoomInButton = getByLabelText("Zoom in") as HTMLButtonElement;
|
|
51
|
+
expect(zoomInButton.disabled).toBe(true);
|
|
52
|
+
|
|
53
|
+
fireEvent.click(zoomInButton);
|
|
54
|
+
expect(onZoomIn).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should disable zoom out button when at minZoom", () => {
|
|
58
|
+
const onZoomOut = vi.fn();
|
|
59
|
+
const { getByLabelText } = render(
|
|
60
|
+
<ZoomControls {...defaultProps} zoom={1} minZoom={1} onZoomOut={onZoomOut} />,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const zoomOutButton = getByLabelText("Zoom out") as HTMLButtonElement;
|
|
64
|
+
expect(zoomOutButton.disabled).toBe(true);
|
|
65
|
+
|
|
66
|
+
fireEvent.click(zoomOutButton);
|
|
67
|
+
expect(onZoomOut).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should hide reset button when showResetButton is false", () => {
|
|
71
|
+
const { getByLabelText } = render(<ZoomControls {...defaultProps} showResetButton={false} />);
|
|
72
|
+
|
|
73
|
+
const resetButton = getByLabelText("Reset zoom");
|
|
74
|
+
const container = resetButton.parentElement as HTMLElement;
|
|
75
|
+
expect(container.style.opacity).toBe("0");
|
|
76
|
+
expect(container.style.pointerEvents).toBe("none");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should render reset button when showResetButton is true", () => {
|
|
80
|
+
const { getByLabelText } = render(
|
|
81
|
+
<ZoomControls {...defaultProps} showResetButton={true} zoom={2} />,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(getByLabelText("Reset zoom")).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should call onReset when reset button is clicked", () => {
|
|
88
|
+
const onReset = vi.fn();
|
|
89
|
+
const { getByLabelText } = render(
|
|
90
|
+
<ZoomControls {...defaultProps} showResetButton={true} zoom={2} onReset={onReset} />,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
fireEvent.click(getByLabelText("Reset zoom"));
|
|
94
|
+
|
|
95
|
+
expect(onReset).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should display reset icon in reset button", () => {
|
|
99
|
+
const { getByLabelText } = render(
|
|
100
|
+
<ZoomControls {...defaultProps} showResetButton={true} zoom={1.5} />,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const resetButton = getByLabelText("Reset zoom");
|
|
104
|
+
expect(resetButton.textContent).toContain("↺");
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/constants.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ColorName, OrganId, StrictColorPalette } from "./types";
|
|
1
|
+
import type { ColorName, HPOLabel, OrganId, StrictColorPalette } from "./types";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* All supported organ IDs
|
|
@@ -87,6 +87,95 @@ export const ORGAN_NAMES_KO: Record<OrganId, string> = {
|
|
|
87
87
|
blood: "혈액",
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* All HPO category labels
|
|
92
|
+
*/
|
|
93
|
+
export const HPO_LABELS: readonly HPOLabel[] = [
|
|
94
|
+
"others",
|
|
95
|
+
"Growth abnormality",
|
|
96
|
+
"Abnormality of the genitourinary system",
|
|
97
|
+
"Abnormality of the immune system",
|
|
98
|
+
"Abnormality of the digestive system",
|
|
99
|
+
"Abnormality of metabolism/homeostasis",
|
|
100
|
+
"Abnormality of head or neck",
|
|
101
|
+
"Abnormality of the musculoskeletal system",
|
|
102
|
+
"Abnormality of the nervous system",
|
|
103
|
+
"Abnormality of the respiratory system",
|
|
104
|
+
"Abnormality of the eye",
|
|
105
|
+
"Abnormality of the cardiovascular system",
|
|
106
|
+
"Abnormality of the ear",
|
|
107
|
+
"Abnormality of prenatal development or birth",
|
|
108
|
+
"Abnormality of the integument",
|
|
109
|
+
"Abnormality of the breast",
|
|
110
|
+
"Abnormality of the endocrine system",
|
|
111
|
+
"Abnormality of blood and blood-forming tissues",
|
|
112
|
+
"Abnormality of limbs",
|
|
113
|
+
"Abnormality of the voice",
|
|
114
|
+
"Constitutional symptom",
|
|
115
|
+
"Neoplasm",
|
|
116
|
+
"Abnormal cellular phenotype",
|
|
117
|
+
"Abnormality of the thoracic cavity",
|
|
118
|
+
] as const;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Mapping from HPO label to OrganId
|
|
122
|
+
* Note: "others" has no corresponding OrganId
|
|
123
|
+
*/
|
|
124
|
+
export const HPO_LABEL_TO_ORGAN: Record<Exclude<HPOLabel, "others">, OrganId> = {
|
|
125
|
+
"Growth abnormality": "growth",
|
|
126
|
+
"Abnormality of the genitourinary system": "kidney",
|
|
127
|
+
"Abnormality of the immune system": "immune",
|
|
128
|
+
"Abnormality of the digestive system": "digestive",
|
|
129
|
+
"Abnormality of metabolism/homeostasis": "metabolism",
|
|
130
|
+
"Abnormality of head or neck": "head",
|
|
131
|
+
"Abnormality of the musculoskeletal system": "muscle",
|
|
132
|
+
"Abnormality of the nervous system": "nervous",
|
|
133
|
+
"Abnormality of the respiratory system": "lung",
|
|
134
|
+
"Abnormality of the eye": "eye",
|
|
135
|
+
"Abnormality of the cardiovascular system": "heart",
|
|
136
|
+
"Abnormality of the ear": "ear",
|
|
137
|
+
"Abnormality of prenatal development or birth": "prenatal",
|
|
138
|
+
"Abnormality of the integument": "integument",
|
|
139
|
+
"Abnormality of the breast": "breast",
|
|
140
|
+
"Abnormality of the endocrine system": "endocrine",
|
|
141
|
+
"Abnormality of blood and blood-forming tissues": "blood",
|
|
142
|
+
"Abnormality of limbs": "limbs",
|
|
143
|
+
"Abnormality of the voice": "voice",
|
|
144
|
+
"Constitutional symptom": "constitutional",
|
|
145
|
+
Neoplasm: "neoplasm",
|
|
146
|
+
"Abnormal cellular phenotype": "cell",
|
|
147
|
+
"Abnormality of the thoracic cavity": "thoracicCavity",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Mapping from OrganId to HPO label
|
|
152
|
+
*/
|
|
153
|
+
export const ORGAN_TO_HPO_LABEL: Record<OrganId, Exclude<HPOLabel, "others">> = {
|
|
154
|
+
growth: "Growth abnormality",
|
|
155
|
+
kidney: "Abnormality of the genitourinary system",
|
|
156
|
+
immune: "Abnormality of the immune system",
|
|
157
|
+
digestive: "Abnormality of the digestive system",
|
|
158
|
+
metabolism: "Abnormality of metabolism/homeostasis",
|
|
159
|
+
head: "Abnormality of head or neck",
|
|
160
|
+
muscle: "Abnormality of the musculoskeletal system",
|
|
161
|
+
nervous: "Abnormality of the nervous system",
|
|
162
|
+
lung: "Abnormality of the respiratory system",
|
|
163
|
+
eye: "Abnormality of the eye",
|
|
164
|
+
heart: "Abnormality of the cardiovascular system",
|
|
165
|
+
ear: "Abnormality of the ear",
|
|
166
|
+
prenatal: "Abnormality of prenatal development or birth",
|
|
167
|
+
integument: "Abnormality of the integument",
|
|
168
|
+
breast: "Abnormality of the breast",
|
|
169
|
+
endocrine: "Abnormality of the endocrine system",
|
|
170
|
+
blood: "Abnormality of blood and blood-forming tissues",
|
|
171
|
+
limbs: "Abnormality of limbs",
|
|
172
|
+
voice: "Abnormality of the voice",
|
|
173
|
+
constitutional: "Constitutional symptom",
|
|
174
|
+
neoplasm: "Neoplasm",
|
|
175
|
+
cell: "Abnormal cellular phenotype",
|
|
176
|
+
thoracicCavity: "Abnormality of the thoracic cavity",
|
|
177
|
+
};
|
|
178
|
+
|
|
90
179
|
/**
|
|
91
180
|
* Color palettes for each color scheme.
|
|
92
181
|
* Based on Tailwind CSS color palette.
|