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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hpo-react-visualizer",
3
- "version": "0.0.1",
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
- "react": "^19.0.0",
46
- "react-dom": "^19.0.0"
34
+ "react": "^18.0.0 || ^19.0.0",
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
+ }
@@ -1,10 +1,12 @@
1
- import { type CSSProperties, useId, useMemo } from "react";
1
+ import { type CSSProperties, useId, useMemo, useState } from "react";
2
2
  import { BODY_VIEWBOX, DEFAULT_COLOR_NAME, ORGAN_IDS } from "./constants";
3
3
  import { createStrictColorPalette } from "./lib";
4
4
  import { OrganSvg } from "./OrganSvg";
5
5
  import { Body } from "./svg/Body";
6
6
  import type { HpoVisualizerProps, OrganConfig, OrganId, StrictColorPalette } from "./types";
7
7
  import { useOrganInteraction } from "./useOrganInteraction";
8
+ import { useZoom } from "./useZoom";
9
+ import { ZoomControls } from "./ZoomControls";
8
10
 
9
11
  /**
10
12
  * Default positions and viewBox dimensions for each organ in the visualizer
@@ -66,6 +68,10 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
66
68
  "blood",
67
69
  ] as const;
68
70
 
71
+ type RenderEntry = { type: "organ"; organId: OrganId } | { type: "body" };
72
+
73
+ const MIN_ZOOM = 1;
74
+
69
75
  /**
70
76
  * HPO Visualizer - Interactive human organ visualization component
71
77
  *
@@ -75,6 +81,7 @@ const FOREGROUND_ORGANS: readonly OrganId[] = [
75
81
  * - Click to select/deselect organs
76
82
  * - Hover effects work independently even when an organ is selected
77
83
  * - Exposes hover and selection state via callbacks
84
+ * - Zoom and pan support with mouse wheel and drag
78
85
  */
79
86
  export function HpoVisualizer({
80
87
  organs,
@@ -84,12 +91,30 @@ export function HpoVisualizer({
84
91
  onHover,
85
92
  onSelect,
86
93
  colorPalette: inputColorPalette,
87
- width = BODY_VIEWBOX.width,
88
- height = BODY_VIEWBOX.height,
94
+ width = "100%",
95
+ height = "100%",
89
96
  className,
90
97
  style,
98
+ maxZoom = 5,
99
+ wheelZoom = true,
91
100
  }: HpoVisualizerProps) {
92
101
  const visualizerID = useId();
102
+ const [isHovering, setIsHovering] = useState(false);
103
+
104
+ // Zoom and pan functionality
105
+ const {
106
+ zoom,
107
+ pan,
108
+ zoomIn,
109
+ zoomOut,
110
+ resetZoom,
111
+ handleMouseDown,
112
+ handleMouseMove,
113
+ handleMouseUp,
114
+ isDragging,
115
+ isDefaultZoom,
116
+ containerRef,
117
+ } = useZoom({ minZoom: MIN_ZOOM, maxZoom, wheelZoom, viewBox: BODY_VIEWBOX });
93
118
 
94
119
  // Create strict color palette with all required colors and alpha values
95
120
  const colorPalette: StrictColorPalette = useMemo(
@@ -124,13 +149,54 @@ export function HpoVisualizer({
124
149
  }, [organs]);
125
150
 
126
151
  // Use the interaction hook
127
- const { handlers, isHovered, isSelected } = useOrganInteraction({
152
+ const { handlers, isHovered, isSelected, state } = useOrganInteraction({
128
153
  hoveredOrgan: controlledHovered,
129
154
  selectedOrgan: controlledSelected,
130
155
  onHover,
131
156
  onSelect,
132
157
  });
133
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
+
134
200
  const containerStyle: CSSProperties = {
135
201
  display: "flex",
136
202
  justifyContent: "center",
@@ -139,12 +205,43 @@ export function HpoVisualizer({
139
205
  position: "relative",
140
206
  width: width,
141
207
  height: height,
142
- ...style,
208
+ ...containerStyleOverrides,
209
+ // Apply overflow after style spread to ensure it takes precedence
210
+ overflow: "clip",
211
+ };
212
+
213
+ const contentStyle: CSSProperties = {
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 = {
234
+ width: "100%",
235
+ height: "100%",
236
+ display: "block",
237
+ cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
238
+ overflow: "visible",
143
239
  };
144
240
 
145
241
  const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
146
- const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
147
- 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})`;
148
245
 
149
246
  // Helper function to render an organ
150
247
  const renderOrgan = (organId: OrganId, isVisible: boolean) => {
@@ -156,10 +253,10 @@ export function HpoVisualizer({
156
253
  return null;
157
254
  }
158
255
 
159
- const x = translateX + position.x * scale;
160
- const y = position.y * scale;
161
- const width = position.width * scale;
162
- 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;
163
260
 
164
261
  return (
165
262
  <OrganSvg
@@ -184,27 +281,62 @@ export function HpoVisualizer({
184
281
  };
185
282
 
186
283
  return (
187
- <div className={className} style={containerStyle}>
188
- {BACKGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))}
189
- {/* Background Body - rendered as nested SVG for flat structure */}
190
- <svg
191
- x={translateX}
192
- y="0"
193
- width={width}
194
- height={height}
195
- viewBox={viewBox}
196
- style={{ position: "absolute", top: "0", left: "0" }}
197
- pointerEvents="none"
198
- >
199
- <title>Human body</title>
200
- <Body
201
- colorScale={colorPalette[DEFAULT_COLOR_NAME]}
202
- style={{
203
- fill: "#fff",
204
- }}
205
- />
206
- </svg>
207
- {FOREGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))}
284
+ <div
285
+ className={className}
286
+ style={containerStyle}
287
+ onMouseEnter={() => setIsHovering(true)}
288
+ onMouseLeave={() => {
289
+ setIsHovering(false);
290
+ handleMouseUp();
291
+ }}
292
+ onMouseDown={handleMouseDown}
293
+ onMouseMove={handleMouseMove}
294
+ onMouseUp={handleMouseUp}
295
+ role="application"
296
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: Required for keyboard accessibility in zoom/pan application
297
+ tabIndex={0}
298
+ >
299
+ <div style={contentStyle}>
300
+ <svg
301
+ ref={containerRef}
302
+ width="100%"
303
+ height="100%"
304
+ viewBox={viewBox}
305
+ preserveAspectRatio="xMidYMid meet"
306
+ style={svgStyle}
307
+ aria-label="Human organ visualizer"
308
+ >
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>
328
+ </svg>
329
+ </div>
330
+ <ZoomControls
331
+ onZoomIn={zoomIn}
332
+ onZoomOut={zoomOut}
333
+ onReset={resetZoom}
334
+ showResetButton={!isDefaultZoom}
335
+ zoom={zoom}
336
+ minZoom={MIN_ZOOM}
337
+ maxZoom={maxZoom}
338
+ isVisible={isHovering}
339
+ />
208
340
  </div>
209
341
  );
210
342
  }
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,125 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ interface ZoomControlsProps {
4
+ /** Callback when zoom in button is clicked */
5
+ onZoomIn: () => void;
6
+ /** Callback when zoom out button is clicked */
7
+ onZoomOut: () => void;
8
+ /** Callback when reset button is clicked */
9
+ onReset: () => void;
10
+ /** Whether to show the reset button */
11
+ showResetButton: boolean;
12
+ /** Current zoom level (1 = 100%) */
13
+ zoom: number;
14
+ /** Minimum zoom level */
15
+ minZoom: number;
16
+ /** Maximum zoom level */
17
+ maxZoom: number;
18
+ /** Whether the controls are visible (for fade effect) */
19
+ isVisible: boolean;
20
+ }
21
+
22
+ const buttonBaseStyle: CSSProperties = {
23
+ width: 32,
24
+ height: 32,
25
+ display: "flex",
26
+ alignItems: "center",
27
+ justifyContent: "center",
28
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
29
+ border: "1px solid #d1d5db",
30
+ borderRadius: 6,
31
+ cursor: "pointer",
32
+ fontSize: 18,
33
+ fontWeight: "bold",
34
+ color: "#374151",
35
+ transition: "background-color 0.15s ease",
36
+ userSelect: "none",
37
+ };
38
+
39
+ const buttonDisabledStyle: CSSProperties = {
40
+ ...buttonBaseStyle,
41
+ opacity: 0.4,
42
+ cursor: "not-allowed",
43
+ };
44
+
45
+ const getContainerStyle = (isVisible: boolean): CSSProperties => ({
46
+ position: "absolute",
47
+ bottom: 12,
48
+ right: 12,
49
+ display: "flex",
50
+ flexDirection: "column",
51
+ gap: 4,
52
+ zIndex: 10,
53
+ opacity: isVisible ? 1 : 0,
54
+ transition: "opacity 0.2s ease-in-out",
55
+ pointerEvents: isVisible ? "auto" : "none",
56
+ });
57
+
58
+ const getResetContainerStyle = (isVisible: boolean): CSSProperties => ({
59
+ position: "absolute",
60
+ bottom: 12,
61
+ left: 12,
62
+ zIndex: 10,
63
+ opacity: isVisible ? 1 : 0,
64
+ transition: "opacity 0.2s ease-in-out",
65
+ pointerEvents: isVisible ? "auto" : "none",
66
+ });
67
+
68
+ const resetButtonStyle: CSSProperties = {
69
+ ...buttonBaseStyle,
70
+ width: "auto",
71
+ padding: "6px 12px",
72
+ fontSize: 12,
73
+ fontWeight: 500,
74
+ };
75
+
76
+ /**
77
+ * Zoom control buttons component
78
+ * Displays +/- buttons in bottom right, reset button in bottom left
79
+ */
80
+ export function ZoomControls({
81
+ onZoomIn,
82
+ onZoomOut,
83
+ onReset,
84
+ showResetButton,
85
+ zoom,
86
+ minZoom,
87
+ maxZoom,
88
+ isVisible,
89
+ }: ZoomControlsProps) {
90
+ const isMinZoom = zoom <= minZoom;
91
+ const isMaxZoom = zoom >= maxZoom;
92
+
93
+ return (
94
+ <>
95
+ {/* Zoom in/out buttons - bottom right */}
96
+ <div style={getContainerStyle(isVisible)}>
97
+ <button
98
+ type="button"
99
+ onClick={onZoomIn}
100
+ disabled={isMaxZoom}
101
+ style={isMaxZoom ? buttonDisabledStyle : buttonBaseStyle}
102
+ aria-label="Zoom in"
103
+ >
104
+ +
105
+ </button>
106
+ <button
107
+ type="button"
108
+ onClick={onZoomOut}
109
+ disabled={isMinZoom}
110
+ style={isMinZoom ? buttonDisabledStyle : buttonBaseStyle}
111
+ aria-label="Zoom out"
112
+ >
113
+
114
+ </button>
115
+ </div>
116
+
117
+ {/* Reset button - bottom left (only shown when not at default zoom) */}
118
+ <div style={getResetContainerStyle(isVisible && showResetButton)}>
119
+ <button type="button" onClick={onReset} style={resetButtonStyle} aria-label="Reset zoom">
120
+
121
+ </button>
122
+ </div>
123
+ </>
124
+ );
125
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { HPO_LABEL_TO_ORGAN, HPO_LABELS, ORGAN_IDS, ORGAN_TO_HPO_LABEL } from "../constants";
4
+ import type { HPOLabel, OrganId } from "../types";
5
+
6
+ describe("HPO Labels", () => {
7
+ describe("HPO_LABELS", () => {
8
+ it("should contain all 24 HPO labels", () => {
9
+ expect(HPO_LABELS).toHaveLength(24);
10
+ });
11
+
12
+ it("should include 'others' label", () => {
13
+ expect(HPO_LABELS).toContain("others");
14
+ });
15
+ });
16
+
17
+ describe("HPO_LABEL_TO_ORGAN mapping", () => {
18
+ it("should map all HPO labels except 'others' to OrganId", () => {
19
+ const labelsWithoutOthers = HPO_LABELS.filter((label) => label !== "others");
20
+ expect(Object.keys(HPO_LABEL_TO_ORGAN)).toHaveLength(labelsWithoutOthers.length);
21
+ });
22
+
23
+ it("should map each HPO label to a valid OrganId", () => {
24
+ for (const [_label, organId] of Object.entries(HPO_LABEL_TO_ORGAN)) {
25
+ expect(ORGAN_IDS).toContain(organId);
26
+ }
27
+ });
28
+
29
+ it("should correctly map specific HPO labels", () => {
30
+ expect(HPO_LABEL_TO_ORGAN["Abnormality of the digestive system"]).toBe("digestive");
31
+ expect(HPO_LABEL_TO_ORGAN["Growth abnormality"]).toBe("growth");
32
+ expect(HPO_LABEL_TO_ORGAN["Abnormality of the eye"]).toBe("eye");
33
+ });
34
+ });
35
+
36
+ describe("ORGAN_TO_HPO_LABEL mapping", () => {
37
+ it("should map all OrganIds to HPO labels", () => {
38
+ expect(Object.keys(ORGAN_TO_HPO_LABEL)).toHaveLength(ORGAN_IDS.length);
39
+ });
40
+
41
+ it("should map each OrganId to a valid HPO label", () => {
42
+ for (const [_organId, label] of Object.entries(ORGAN_TO_HPO_LABEL)) {
43
+ expect(HPO_LABELS).toContain(label);
44
+ }
45
+ });
46
+
47
+ it("should correctly map specific OrganIds", () => {
48
+ expect(ORGAN_TO_HPO_LABEL.digestive).toBe("Abnormality of the digestive system");
49
+ expect(ORGAN_TO_HPO_LABEL.growth).toBe("Growth abnormality");
50
+ expect(ORGAN_TO_HPO_LABEL.eye).toBe("Abnormality of the eye");
51
+ });
52
+ });
53
+
54
+ describe("Bidirectional mapping consistency", () => {
55
+ it("should have consistent bidirectional mapping", () => {
56
+ // HPO_LABEL_TO_ORGAN → ORGAN_TO_HPO_LABEL should return original label
57
+ for (const [label, organId] of Object.entries(HPO_LABEL_TO_ORGAN)) {
58
+ const reverseLabel = ORGAN_TO_HPO_LABEL[organId as OrganId];
59
+ expect(reverseLabel).toBe(label);
60
+ }
61
+ });
62
+
63
+ it("should have consistent reverse mapping", () => {
64
+ // ORGAN_TO_HPO_LABEL → HPO_LABEL_TO_ORGAN should return original organId
65
+ for (const [organId, label] of Object.entries(ORGAN_TO_HPO_LABEL)) {
66
+ const reverseOrganId = HPO_LABEL_TO_ORGAN[label as Exclude<HPOLabel, "others">];
67
+ expect(reverseOrganId).toBe(organId);
68
+ }
69
+ });
70
+ });
71
+ });
@@ -0,0 +1,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
+ });