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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
|
|
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
|
+
});
|
|
144
|
+
});
|
|
@@ -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.
|
|
@@ -147,7 +236,20 @@ export const ANIMATION_DURATION_MS = 150;
|
|
|
147
236
|
/**
|
|
148
237
|
* CSS transition string for smooth style changes
|
|
149
238
|
*/
|
|
150
|
-
|
|
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(", ");
|
|
151
253
|
|
|
152
254
|
/**
|
|
153
255
|
* Body SVG viewBox dimensions
|
package/src/index.ts
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
export {
|
|
5
5
|
ANIMATION_DURATION_MS,
|
|
6
6
|
DEFAULT_COLOR_PALETTE,
|
|
7
|
+
HPO_LABEL_TO_ORGAN,
|
|
8
|
+
HPO_LABELS,
|
|
7
9
|
ORGAN_IDS,
|
|
8
10
|
ORGAN_NAMES_EN,
|
|
9
11
|
ORGAN_NAMES_KO,
|
|
12
|
+
ORGAN_TO_HPO_LABEL,
|
|
10
13
|
} from "./constants";
|
|
11
14
|
export { HpoVisualizer } from "./HpoVisualizer";
|
|
15
|
+
export { createOrganOutlineSet, createUniformOrganColorSchemes } from "./lib";
|
|
12
16
|
export type { OrganSvgWrapperProps } from "./OrganSvg";
|
|
13
17
|
// OrganSvg wrapper for custom compositions
|
|
14
18
|
export { OrganSvg } from "./OrganSvg";
|
|
@@ -18,6 +22,7 @@ export { ORGAN_COMPONENTS } from "./svg/index";
|
|
|
18
22
|
export type {
|
|
19
23
|
ColorName as ColorScheme,
|
|
20
24
|
ColorScale as ColorPalette,
|
|
25
|
+
HPOLabel,
|
|
21
26
|
HpoVisualizerProps,
|
|
22
27
|
OrganConfig,
|
|
23
28
|
OrganId,
|
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
|
@@ -28,10 +28,41 @@ export type OrganId =
|
|
|
28
28
|
| "prenatal"
|
|
29
29
|
| "blood";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* HPO official category labels
|
|
33
|
+
* @see https://hpo.jax.org/
|
|
34
|
+
*/
|
|
35
|
+
export type HPOLabel =
|
|
36
|
+
| "others"
|
|
37
|
+
| "Growth abnormality"
|
|
38
|
+
| "Abnormality of the genitourinary system"
|
|
39
|
+
| "Abnormality of the immune system"
|
|
40
|
+
| "Abnormality of the digestive system"
|
|
41
|
+
| "Abnormality of metabolism/homeostasis"
|
|
42
|
+
| "Abnormality of head or neck"
|
|
43
|
+
| "Abnormality of the musculoskeletal system"
|
|
44
|
+
| "Abnormality of the nervous system"
|
|
45
|
+
| "Abnormality of the respiratory system"
|
|
46
|
+
| "Abnormality of the eye"
|
|
47
|
+
| "Abnormality of the cardiovascular system"
|
|
48
|
+
| "Abnormality of the ear"
|
|
49
|
+
| "Abnormality of prenatal development or birth"
|
|
50
|
+
| "Abnormality of the integument"
|
|
51
|
+
| "Abnormality of the breast"
|
|
52
|
+
| "Abnormality of the endocrine system"
|
|
53
|
+
| "Abnormality of blood and blood-forming tissues"
|
|
54
|
+
| "Abnormality of limbs"
|
|
55
|
+
| "Abnormality of the voice"
|
|
56
|
+
| "Constitutional symptom"
|
|
57
|
+
| "Neoplasm"
|
|
58
|
+
| "Abnormal cellular phenotype"
|
|
59
|
+
| "Abnormality of the thoracic cavity";
|
|
60
|
+
|
|
31
61
|
/**
|
|
32
62
|
* Color scheme for each organ.
|
|
33
63
|
*/
|
|
34
64
|
export type ColorName = "blue" | "yellow" | "gray";
|
|
65
|
+
export type ColorScheme = ColorName;
|
|
35
66
|
/**
|
|
36
67
|
* Color step
|
|
37
68
|
*/
|
|
@@ -159,4 +190,8 @@ export interface HpoVisualizerProps {
|
|
|
159
190
|
className?: string;
|
|
160
191
|
/** Additional inline styles */
|
|
161
192
|
style?: CSSProperties;
|
|
193
|
+
/** Maximum zoom level (default: 5 = 500%) */
|
|
194
|
+
maxZoom?: number;
|
|
195
|
+
/** Enable zoom with mouse wheel (default: true) */
|
|
196
|
+
wheelZoom?: boolean;
|
|
162
197
|
}
|
|
@@ -33,6 +33,7 @@ export interface UseOrganInteractionResult {
|
|
|
33
33
|
* - Click on organ sets selectedOrgan and calls onSelect
|
|
34
34
|
* - Click on already selected organ deselects it
|
|
35
35
|
* - Click on different organ changes selection
|
|
36
|
+
* - Deselect keeps hover state when still over the organ
|
|
36
37
|
* - Hover effects work independently even when an organ is selected
|
|
37
38
|
*/
|
|
38
39
|
export function useOrganInteraction(
|
|
@@ -82,16 +83,8 @@ export function useOrganInteraction(
|
|
|
82
83
|
setInternalSelected(newSelected);
|
|
83
84
|
}
|
|
84
85
|
onSelect?.(newSelected);
|
|
85
|
-
|
|
86
|
-
// When deselecting, also clear hover state
|
|
87
|
-
if (newSelected === null) {
|
|
88
|
-
if (!isHoverControlled) {
|
|
89
|
-
setInternalHovered(null);
|
|
90
|
-
}
|
|
91
|
-
onHover?.(null);
|
|
92
|
-
}
|
|
93
86
|
},
|
|
94
|
-
[selectedOrgan, isSelectControlled,
|
|
87
|
+
[selectedOrgan, isSelectControlled, onSelect],
|
|
95
88
|
);
|
|
96
89
|
|
|
97
90
|
const state: OrganInteractionState = useMemo(
|