hpo-react-visualizer 0.0.2 → 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 +335 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -6
- package/dist/index.d.ts +13 -6
- package/dist/index.js +334 -136
- package/dist/index.js.map +1 -1
- package/package.json +15 -15
- package/src/HpoVisualizer.tsx +103 -37
- package/src/OrganSvg.tsx +13 -17
- 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 +34 -0
- package/src/constants.ts +14 -1
- package/src/index.ts +1 -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 +3 -0
- package/src/useOrganInteraction.ts +2 -9
- package/src/useZoom.ts +224 -73
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
34
|
"react": "^18.0.0 || ^19.0.0",
|
|
46
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
|
@@ -68,6 +68,8 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
|
|
|
68
68
|
"blood",
|
|
69
69
|
] as const;
|
|
70
70
|
|
|
71
|
+
type RenderEntry = { type: "organ"; organId: OrganId } | { type: "body" };
|
|
72
|
+
|
|
71
73
|
const MIN_ZOOM = 1;
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -89,11 +91,12 @@ export function HpoVisualizer({
|
|
|
89
91
|
onHover,
|
|
90
92
|
onSelect,
|
|
91
93
|
colorPalette: inputColorPalette,
|
|
92
|
-
width =
|
|
93
|
-
height =
|
|
94
|
+
width = "100%",
|
|
95
|
+
height = "100%",
|
|
94
96
|
className,
|
|
95
97
|
style,
|
|
96
98
|
maxZoom = 5,
|
|
99
|
+
wheelZoom = true,
|
|
97
100
|
}: HpoVisualizerProps) {
|
|
98
101
|
const visualizerID = useId();
|
|
99
102
|
const [isHovering, setIsHovering] = useState(false);
|
|
@@ -111,7 +114,7 @@ export function HpoVisualizer({
|
|
|
111
114
|
isDragging,
|
|
112
115
|
isDefaultZoom,
|
|
113
116
|
containerRef,
|
|
114
|
-
} = useZoom({ minZoom: MIN_ZOOM, maxZoom });
|
|
117
|
+
} = useZoom({ minZoom: MIN_ZOOM, maxZoom, wheelZoom, viewBox: BODY_VIEWBOX });
|
|
115
118
|
|
|
116
119
|
// Create strict color palette with all required colors and alpha values
|
|
117
120
|
const colorPalette: StrictColorPalette = useMemo(
|
|
@@ -146,13 +149,54 @@ export function HpoVisualizer({
|
|
|
146
149
|
}, [organs]);
|
|
147
150
|
|
|
148
151
|
// Use the interaction hook
|
|
149
|
-
const { handlers, isHovered, isSelected } = useOrganInteraction({
|
|
152
|
+
const { handlers, isHovered, isSelected, state } = useOrganInteraction({
|
|
150
153
|
hoveredOrgan: controlledHovered,
|
|
151
154
|
selectedOrgan: controlledSelected,
|
|
152
155
|
onHover,
|
|
153
156
|
onSelect,
|
|
154
157
|
});
|
|
155
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
|
+
|
|
156
200
|
const containerStyle: CSSProperties = {
|
|
157
201
|
display: "flex",
|
|
158
202
|
justifyContent: "center",
|
|
@@ -161,25 +205,43 @@ export function HpoVisualizer({
|
|
|
161
205
|
position: "relative",
|
|
162
206
|
width: width,
|
|
163
207
|
height: height,
|
|
164
|
-
...
|
|
208
|
+
...containerStyleOverrides,
|
|
165
209
|
// Apply overflow after style spread to ensure it takes precedence
|
|
166
|
-
// Use 'clip' instead of 'hidden' to respect padding
|
|
167
210
|
overflow: "clip",
|
|
168
211
|
};
|
|
169
212
|
|
|
170
213
|
const contentStyle: CSSProperties = {
|
|
171
|
-
|
|
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 = {
|
|
172
234
|
width: "100%",
|
|
173
235
|
height: "100%",
|
|
174
|
-
|
|
175
|
-
transformOrigin: "center center",
|
|
236
|
+
display: "block",
|
|
176
237
|
cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
|
|
177
|
-
|
|
238
|
+
overflow: "visible",
|
|
178
239
|
};
|
|
179
240
|
|
|
180
241
|
const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
|
|
181
|
-
const
|
|
182
|
-
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})`;
|
|
183
245
|
|
|
184
246
|
// Helper function to render an organ
|
|
185
247
|
const renderOrgan = (organId: OrganId, isVisible: boolean) => {
|
|
@@ -191,10 +253,10 @@ export function HpoVisualizer({
|
|
|
191
253
|
return null;
|
|
192
254
|
}
|
|
193
255
|
|
|
194
|
-
const x =
|
|
195
|
-
const y = position.y
|
|
196
|
-
const width = position.width
|
|
197
|
-
const height = position.height
|
|
256
|
+
const x = position.x;
|
|
257
|
+
const y = position.y;
|
|
258
|
+
const width = position.width;
|
|
259
|
+
const height = position.height;
|
|
198
260
|
|
|
199
261
|
return (
|
|
200
262
|
<OrganSvg
|
|
@@ -220,7 +282,6 @@ export function HpoVisualizer({
|
|
|
220
282
|
|
|
221
283
|
return (
|
|
222
284
|
<div
|
|
223
|
-
ref={containerRef}
|
|
224
285
|
className={className}
|
|
225
286
|
style={containerStyle}
|
|
226
287
|
onMouseEnter={() => setIsHovering(true)}
|
|
@@ -236,30 +297,35 @@ export function HpoVisualizer({
|
|
|
236
297
|
tabIndex={0}
|
|
237
298
|
>
|
|
238
299
|
<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
300
|
<svg
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
height={height}
|
|
301
|
+
ref={containerRef}
|
|
302
|
+
width="100%"
|
|
303
|
+
height="100%"
|
|
248
304
|
viewBox={viewBox}
|
|
249
|
-
|
|
250
|
-
|
|
305
|
+
preserveAspectRatio="xMidYMid meet"
|
|
306
|
+
style={svgStyle}
|
|
307
|
+
aria-label="Human organ visualizer"
|
|
251
308
|
>
|
|
252
|
-
<title>Human
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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>
|
|
259
328
|
</svg>
|
|
260
|
-
{FOREGROUND_ORGANS.map((organId) =>
|
|
261
|
-
renderOrgan(organId, visibleOrganIds.includes(organId)),
|
|
262
|
-
)}
|
|
263
329
|
</div>
|
|
264
330
|
<ZoomControls
|
|
265
331
|
onZoomIn={zoomIn}
|
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,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
|
+
});
|
|
@@ -6,15 +6,12 @@ import type { OrganId } from "../types";
|
|
|
6
6
|
describe("HpoVisualizer rendering order", () => {
|
|
7
7
|
/**
|
|
8
8
|
* Helper function to get rendered organ IDs in DOM order.
|
|
9
|
-
* OrganSvg elements are now <
|
|
9
|
+
* OrganSvg elements are now <g> elements with aria-label attribute.
|
|
10
10
|
* Only includes visible organs (opacity !== 0).
|
|
11
11
|
*/
|
|
12
12
|
const getRenderedOrganOrder = (container: HTMLElement): string[] => {
|
|
13
|
-
// Find all OrganSvg elements (they are
|
|
14
|
-
|
|
15
|
-
const organElements = container.querySelectorAll(
|
|
16
|
-
"svg[aria-label]:not([aria-label='Human organ visualizer'])",
|
|
17
|
-
);
|
|
13
|
+
// Find all OrganSvg elements (they are <g> elements with aria-label)
|
|
14
|
+
const organElements = container.querySelectorAll("g[aria-label]");
|
|
18
15
|
return Array.from(organElements)
|
|
19
16
|
.filter((el) => {
|
|
20
17
|
// Filter out hidden organs (opacity: 0)
|
|
@@ -25,16 +22,6 @@ describe("HpoVisualizer rendering order", () => {
|
|
|
25
22
|
.map((el) => el.getAttribute("aria-label") || "");
|
|
26
23
|
};
|
|
27
24
|
|
|
28
|
-
/**
|
|
29
|
-
* Helper function to get z-index of an organ element
|
|
30
|
-
*/
|
|
31
|
-
const getOrganZIndex = (container: HTMLElement, organId: string): number => {
|
|
32
|
-
const organElement = container.querySelector(`svg[aria-label="${organId}"]`) as HTMLElement;
|
|
33
|
-
if (!organElement) return -1;
|
|
34
|
-
const style = organElement.style.zIndex;
|
|
35
|
-
return style ? parseInt(style, 10) : 0;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
25
|
it("should render organs in ORGAN_POSITIONS key order regardless of organs array order", () => {
|
|
39
26
|
// Provide organs in reverse alphabetical order
|
|
40
27
|
const organs: { id: OrganId }[] = [
|
|
@@ -64,7 +51,7 @@ describe("HpoVisualizer rendering order", () => {
|
|
|
64
51
|
]);
|
|
65
52
|
});
|
|
66
53
|
|
|
67
|
-
it("should
|
|
54
|
+
it("should render selectedOrgan after other organs to appear on top", () => {
|
|
68
55
|
const organs: { id: OrganId }[] = [
|
|
69
56
|
{ id: "head" },
|
|
70
57
|
{ id: "eye" },
|
|
@@ -76,16 +63,12 @@ describe("HpoVisualizer rendering order", () => {
|
|
|
76
63
|
// Select 'head'
|
|
77
64
|
const { container } = render(<HpoVisualizer organs={organs} selectedOrgan="head" />);
|
|
78
65
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(getOrganZIndex(container, "lung")).toBe(0);
|
|
83
|
-
expect(getOrganZIndex(container, "heart")).toBe(0);
|
|
84
|
-
expect(getOrganZIndex(container, "eye")).toBe(0);
|
|
85
|
-
expect(getOrganZIndex(container, "ear")).toBe(0);
|
|
66
|
+
const renderedOrder = getRenderedOrganOrder(container);
|
|
67
|
+
// 'head' should be rendered last since it's selected
|
|
68
|
+
expect(renderedOrder.at(-1)).toBe("head");
|
|
86
69
|
});
|
|
87
70
|
|
|
88
|
-
it("should
|
|
71
|
+
it("should render selectedOrgan last even when it would normally be in the middle", () => {
|
|
89
72
|
const organs: { id: OrganId }[] = [
|
|
90
73
|
{ id: "head" },
|
|
91
74
|
{ id: "lung" },
|
|
@@ -97,26 +80,19 @@ describe("HpoVisualizer rendering order", () => {
|
|
|
97
80
|
// Select 'heart' which is in the middle of ORGAN_POSITIONS order
|
|
98
81
|
const { container } = render(<HpoVisualizer organs={organs} selectedOrgan="heart" />);
|
|
99
82
|
|
|
100
|
-
|
|
101
|
-
expect(
|
|
102
|
-
|
|
103
|
-
expect(getOrganZIndex(container, "lung")).toBe(0);
|
|
104
|
-
expect(getOrganZIndex(container, "digestive")).toBe(0);
|
|
105
|
-
expect(getOrganZIndex(container, "kidney")).toBe(0);
|
|
106
|
-
expect(getOrganZIndex(container, "head")).toBe(0);
|
|
83
|
+
const renderedOrder = getRenderedOrganOrder(container);
|
|
84
|
+
expect(renderedOrder.at(-1)).toBe("heart");
|
|
85
|
+
expect(renderedOrder.slice(0, -1)).toEqual(["lung", "digestive", "kidney", "head"]);
|
|
107
86
|
});
|
|
108
87
|
|
|
109
|
-
it("should render
|
|
88
|
+
it("should render selected organ last while preserving order for others", () => {
|
|
110
89
|
const organs: { id: OrganId }[] = [{ id: "head" }, { id: "eye" }, { id: "ear" }];
|
|
111
90
|
|
|
112
91
|
const { container } = render(<HpoVisualizer organs={organs} selectedOrgan="eye" />);
|
|
113
92
|
|
|
114
93
|
const renderedOrder = getRenderedOrganOrder(container);
|
|
115
94
|
|
|
116
|
-
|
|
117
|
-
expect(renderedOrder).toEqual(["head", "eye", "ear"]);
|
|
118
|
-
// 'eye' has higher z-index because it's selected
|
|
119
|
-
expect(getOrganZIndex(container, "eye")).toBe(1);
|
|
95
|
+
expect(renderedOrder).toEqual(["head", "ear", "eye"]);
|
|
120
96
|
});
|
|
121
97
|
|
|
122
98
|
it("should not render selectedOrgan if it is not in organs array", () => {
|
|
@@ -143,4 +119,15 @@ describe("HpoVisualizer rendering order", () => {
|
|
|
143
119
|
// Should follow ORGAN_RENDER_ORDER: lung, heart, kidney, head
|
|
144
120
|
expect(renderedOrder).toEqual(["lung", "heart", "kidney", "head"]);
|
|
145
121
|
});
|
|
122
|
+
|
|
123
|
+
it("should return deselected organ to normal order immediately", () => {
|
|
124
|
+
const organs: { id: OrganId }[] = [{ id: "head" }, { id: "eye" }, { id: "ear" }];
|
|
125
|
+
const { container, rerender } = render(<HpoVisualizer organs={organs} selectedOrgan="eye" />);
|
|
126
|
+
|
|
127
|
+
expect(getRenderedOrganOrder(container)).toEqual(["head", "ear", "eye"]);
|
|
128
|
+
|
|
129
|
+
rerender(<HpoVisualizer organs={organs} selectedOrgan={null} />);
|
|
130
|
+
|
|
131
|
+
expect(getRenderedOrganOrder(container)).toEqual(["head", "eye", "ear"]);
|
|
132
|
+
});
|
|
146
133
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { TRANSITION_STYLE } from "../constants";
|
|
4
|
+
|
|
5
|
+
describe("TRANSITION_STYLE", () => {
|
|
6
|
+
it("limits transitions to visual properties", () => {
|
|
7
|
+
expect(TRANSITION_STYLE).not.toContain("all");
|
|
8
|
+
expect(TRANSITION_STYLE).toContain("fill");
|
|
9
|
+
expect(TRANSITION_STYLE).toContain("opacity");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { OrganId } from "../types";
|
|
4
|
+
import { useOrganInteraction } from "../useOrganInteraction";
|
|
5
|
+
|
|
6
|
+
describe("useOrganInteraction hook", () => {
|
|
7
|
+
it("keeps hover state when deselecting a hovered organ", () => {
|
|
8
|
+
const organId: OrganId = "head";
|
|
9
|
+
const { result } = renderHook(() => useOrganInteraction());
|
|
10
|
+
|
|
11
|
+
act(() => {
|
|
12
|
+
result.current.handlers.handleMouseEnter(organId);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(result.current.state.hoveredOrgan).toBe(organId);
|
|
16
|
+
|
|
17
|
+
act(() => {
|
|
18
|
+
result.current.handlers.handleClick(organId);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(result.current.state.selectedOrgan).toBe(organId);
|
|
22
|
+
expect(result.current.state.hoveredOrgan).toBe(organId);
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
result.current.handlers.handleClick(organId);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.current.state.selectedOrgan).toBeNull();
|
|
29
|
+
expect(result.current.state.hoveredOrgan).toBe(organId);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -107,4 +107,38 @@ describe("useZoom hook", () => {
|
|
|
107
107
|
|
|
108
108
|
expect(result.current.isDefaultZoom).toBe(false);
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
describe("wheelZoom option", () => {
|
|
112
|
+
it("should accept wheelZoom option with default value true", () => {
|
|
113
|
+
const { result } = renderHook(() => useZoom());
|
|
114
|
+
|
|
115
|
+
// Hook should work normally with default wheelZoom=true
|
|
116
|
+
expect(result.current.zoom).toBe(1);
|
|
117
|
+
expect(result.current.containerRef).toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should accept wheelZoom option set to false", () => {
|
|
121
|
+
const { result } = renderHook(() => useZoom({ wheelZoom: false }));
|
|
122
|
+
|
|
123
|
+
// Hook should work normally with wheelZoom=false
|
|
124
|
+
expect(result.current.zoom).toBe(1);
|
|
125
|
+
expect(result.current.containerRef).toBeDefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should still allow button zoom when wheelZoom is disabled", () => {
|
|
129
|
+
const { result } = renderHook(() => useZoom({ wheelZoom: false }));
|
|
130
|
+
|
|
131
|
+
act(() => {
|
|
132
|
+
result.current.zoomIn();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(result.current.zoom).toBe(1.5);
|
|
136
|
+
|
|
137
|
+
act(() => {
|
|
138
|
+
result.current.zoomOut();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result.current.zoom).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
110
144
|
});
|
package/src/constants.ts
CHANGED
|
@@ -236,7 +236,20 @@ export const ANIMATION_DURATION_MS = 150;
|
|
|
236
236
|
/**
|
|
237
237
|
* CSS transition string for smooth style changes
|
|
238
238
|
*/
|
|
239
|
-
|
|
239
|
+
const TRANSITION_PROPERTIES = [
|
|
240
|
+
"fill",
|
|
241
|
+
"stroke",
|
|
242
|
+
"stroke-width",
|
|
243
|
+
"opacity",
|
|
244
|
+
"filter",
|
|
245
|
+
"stop-color",
|
|
246
|
+
"stop-opacity",
|
|
247
|
+
"visibility",
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
export const TRANSITION_STYLE = TRANSITION_PROPERTIES.map(
|
|
251
|
+
(property) => `${property} ${ANIMATION_DURATION_MS}ms ease-out`,
|
|
252
|
+
).join(", ");
|
|
240
253
|
|
|
241
254
|
/**
|
|
242
255
|
* Body SVG viewBox dimensions
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
ORGAN_TO_HPO_LABEL,
|
|
13
13
|
} from "./constants";
|
|
14
14
|
export { HpoVisualizer } from "./HpoVisualizer";
|
|
15
|
+
export { createOrganOutlineSet, createUniformOrganColorSchemes } from "./lib";
|
|
15
16
|
export type { OrganSvgWrapperProps } from "./OrganSvg";
|
|
16
17
|
// OrganSvg wrapper for custom compositions
|
|
17
18
|
export { OrganSvg } from "./OrganSvg";
|
package/src/lib/index.ts
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ColorScheme, OrganId } from "../types";
|
|
2
|
+
|
|
3
|
+
export const createUniformOrganColorSchemes = (
|
|
4
|
+
organIds: readonly OrganId[],
|
|
5
|
+
scheme: ColorScheme,
|
|
6
|
+
): Record<OrganId, ColorScheme> => {
|
|
7
|
+
return organIds.reduce(
|
|
8
|
+
(acc, organId) => {
|
|
9
|
+
acc[organId] = scheme;
|
|
10
|
+
return acc;
|
|
11
|
+
},
|
|
12
|
+
{} as Record<OrganId, ColorScheme>,
|
|
13
|
+
);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const createOrganOutlineSet = (
|
|
17
|
+
organIds: readonly OrganId[],
|
|
18
|
+
enabled: boolean,
|
|
19
|
+
): Set<OrganId> => {
|
|
20
|
+
return enabled ? new Set(organIds) : new Set<OrganId>();
|
|
21
|
+
};
|
package/src/svg/Neoplasm.tsx
CHANGED
|
@@ -15,9 +15,12 @@ export function Neoplasm({ style, colorScale, isActive = false, className }: Org
|
|
|
15
15
|
strokeWidth={style?.strokeWidth}
|
|
16
16
|
style={{ transition: TRANSITION_STYLE }}
|
|
17
17
|
/>
|
|
18
|
+
<path d={OUTLINE_NEOPLASM_PATH} fill="transparent" style={{ transition: TRANSITION_STYLE }} />
|
|
18
19
|
</g>
|
|
19
20
|
);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const NEOPLASM_PATH =
|
|
23
24
|
"M2.7706 0.345092C3.77204 -0.328406 4.67398 0.0748652 5.51994 0.775559C5.66929 0.84341 5.84084 0.854726 6.00335 0.870091C6.75039 0.90371 7.23202 1.47634 7.71526 1.9904C7.9442 2.15206 8.23991 2.13397 8.49487 2.22712C9.42032 2.53001 9.97875 3.53109 9.82848 4.52946C9.80496 4.72076 9.82624 4.9113 9.95893 5.05368C10.8667 5.72367 11.227 6.90427 10.8552 8.00289C10.8197 8.17335 10.8234 8.33612 10.8552 8.50914C11.1667 9.57255 10.4627 10.7371 9.42026 10.9099C9.00986 10.9605 8.83573 11.0538 8.57313 11.3857C7.90654 12.1783 6.69443 12.1982 5.99107 11.4787C5.5305 10.923 5.59744 10.7815 4.83778 10.6419C4.274 10.5136 4.05225 9.90237 3.47347 9.82554C2.48067 9.75886 1.64607 8.89953 1.56283 7.86148C1.52718 7.50386 1.66051 7.13178 1.52369 6.78727C1.3217 6.37915 1.32115 5.92532 1.23057 5.49352C1.10172 5.15452 0.710231 4.98692 0.513122 4.69274C-0.603239 3.37518 0.214433 1.09705 1.8828 0.870872C2.29014 0.811338 2.46031 0.574268 2.7706 0.345092ZM8.66675 8.95211C9.38993 8.35162 8.63882 7.25972 7.84647 7.70601C7.45807 7.89616 7.2217 7.60732 6.78065 7.86148C5.19403 8.99021 7.21312 10.9585 8.28615 9.18961C8.40479 9.09758 8.54993 9.05178 8.66675 8.95211ZM4.7104 6.27711L4.27303 6.33727C4.12492 6.3579 3.97681 6.38207 3.83565 6.41852C2.87888 6.71014 2.93127 8.12397 3.79805 8.44664C4.34702 8.66977 4.91047 8.31107 5.14548 7.78492C5.25444 7.61852 5.42561 7.46995 5.49231 7.2693C5.67652 6.73389 5.241 6.21214 4.7104 6.27711ZM8.15954 5.40524C8.913 4.35054 7.9177 2.94878 6.76301 3.66071C6.55641 3.79164 6.34141 3.80113 6.11308 3.86227C5.20786 4.14228 5.25696 5.42232 6.06934 5.76071C6.24349 5.83273 6.44219 5.82242 6.57808 5.96071C6.82774 6.35991 7.1629 6.64138 7.63085 6.42633C8.04922 6.26821 8.02001 5.76856 8.15954 5.40524ZM4.52778 2.60368C4.44638 2.07782 3.90894 1.82777 3.46273 2.07087C3.13279 2.34905 2.82713 2.08984 2.46597 2.10681C1.16526 2.23008 1.24741 4.22477 2.54961 4.25133C2.72681 4.26447 2.8382 4.34664 2.91869 4.51149C3.71452 6.01766 5.6609 4.68642 4.53008 3.18805C4.47929 2.99637 4.57022 2.79792 4.52778 2.60368Z";
|
|
25
|
+
const OUTLINE_NEOPLASM_PATH =
|
|
26
|
+
"M2.7706 0.345092C3.77204 -0.328406 4.67398 0.0748652 5.51994 0.775559C5.66929 0.84341 5.84084 0.854726 6.00335 0.870091C6.75039 0.90371 7.23202 1.47634 7.71526 1.9904C7.9442 2.15206 8.23991 2.13397 8.49487 2.22712C9.42032 2.53001 9.97875 3.53109 9.82848 4.52946C9.80497 4.72076 9.82624 4.9113 9.95893 5.05368C10.8667 5.72367 11.227 6.90427 10.8552 8.00289C10.8197 8.17335 10.8234 8.33612 10.8552 8.50914C11.1667 9.57254 10.4627 10.7371 9.42026 10.9099C9.00986 10.9605 8.83573 11.0538 8.57313 11.3857C7.90654 12.1783 6.69443 12.1982 5.99108 11.4787C5.5305 10.923 5.59744 10.7815 4.83778 10.6419C4.274 10.5136 4.05225 9.90237 3.47347 9.82554C2.48067 9.75886 1.64607 8.89953 1.56283 7.86148C1.52718 7.50386 1.66051 7.13178 1.52369 6.78727C1.3217 6.37915 1.32115 5.92532 1.23057 5.49352C1.10172 5.15452 0.710231 4.98692 0.513122 4.69274C-0.603239 3.37518 0.214433 1.09705 1.8828 0.870872C2.29014 0.811338 2.46031 0.574268 2.7706 0.345092Z";
|
package/src/types.ts
CHANGED
|
@@ -62,6 +62,7 @@ export type HPOLabel =
|
|
|
62
62
|
* Color scheme for each organ.
|
|
63
63
|
*/
|
|
64
64
|
export type ColorName = "blue" | "yellow" | "gray";
|
|
65
|
+
export type ColorScheme = ColorName;
|
|
65
66
|
/**
|
|
66
67
|
* Color step
|
|
67
68
|
*/
|
|
@@ -191,4 +192,6 @@ export interface HpoVisualizerProps {
|
|
|
191
192
|
style?: CSSProperties;
|
|
192
193
|
/** Maximum zoom level (default: 5 = 500%) */
|
|
193
194
|
maxZoom?: number;
|
|
195
|
+
/** Enable zoom with mouse wheel (default: true) */
|
|
196
|
+
wheelZoom?: boolean;
|
|
194
197
|
}
|