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.
@@ -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 <svg> elements with aria-label attribute.
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 nested svg elements with aria-label)
14
- // Exclude the root svg which has aria-label="Human organ visualizer"
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 give selectedOrgan higher z-index to appear on top", () => {
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
- // 'head' should have higher z-index since it's selected
80
- expect(getOrganZIndex(container, "head")).toBe(1);
81
- // Other organs should have z-index 0
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 give selectedOrgan higher z-index even when it would normally be in the middle", () => {
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
- // 'heart' should have higher z-index
101
- expect(getOrganZIndex(container, "heart")).toBe(1);
102
- // Other organs should have z-index 0
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 all organs in ORGAN_RENDER_ORDER (no reordering for selection)", () => {
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
- // All organs should be rendered in ORGAN_RENDER_ORDER, not reordered
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
- export const TRANSITION_STYLE = `all ${ANIMATION_DURATION_MS}ms ease-out`;
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
@@ -1 +1,2 @@
1
1
  export { createStrictColorPalette } from "./createStrictColorPalette";
2
+ export { createOrganOutlineSet, createUniformOrganColorSchemes } from "./organControlState";
@@ -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
+ };
@@ -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, isHoverControlled, onSelect, onHover],
87
+ [selectedOrgan, isSelectControlled, onSelect],
95
88
  );
96
89
 
97
90
  const state: OrganInteractionState = useMemo(