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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hpo-react-visualizer",
3
- "version": "0.0.2",
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
+ }
@@ -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 = BODY_VIEWBOX.width,
93
- height = BODY_VIEWBOX.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
- ...style,
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
- position: "relative",
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
- transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
175
- transformOrigin: "center center",
236
+ display: "block",
176
237
  cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
177
- transition: isDragging ? "none" : "transform 0.1s ease-out",
238
+ overflow: "visible",
178
239
  };
179
240
 
180
241
  const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
181
- const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
182
- const translateX = (Number(width) - BODY_VIEWBOX.width * scale) / 2;
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 = translateX + position.x * scale;
195
- const y = position.y * scale;
196
- const width = position.width * scale;
197
- const height = position.height * scale;
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
- x={translateX}
245
- y="0"
246
- width={width}
247
- height={height}
301
+ ref={containerRef}
302
+ width="100%"
303
+ height="100%"
248
304
  viewBox={viewBox}
249
- style={{ position: "absolute", top: "0", left: "0" }}
250
- pointerEvents="none"
305
+ preserveAspectRatio="xMidYMid meet"
306
+ style={svgStyle}
307
+ aria-label="Human organ visualizer"
251
308
  >
252
- <title>Human body</title>
253
- <Body
254
- colorScale={colorPalette[DEFAULT_COLOR_NAME]}
255
- style={{
256
- fill: "#fff",
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 svgStyle: CSSProperties = {
97
- position: "absolute",
98
- left: x,
99
- top: y,
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
- <svg
107
- width={width}
108
- height={height}
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
- </svg>
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 <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
+ });
@@ -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
- 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(", ");
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
@@ -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
@@ -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
  }