hpo-react-visualizer 0.0.1 → 0.0.2

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/src/index.ts CHANGED
@@ -4,9 +4,12 @@
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";
12
15
  export type { OrganSvgWrapperProps } from "./OrganSvg";
@@ -18,6 +21,7 @@ export { ORGAN_COMPONENTS } from "./svg/index";
18
21
  export type {
19
22
  ColorName as ColorScheme,
20
23
  ColorScale as ColorPalette,
24
+ HPOLabel,
21
25
  HpoVisualizerProps,
22
26
  OrganConfig,
23
27
  OrganId,
package/src/types.ts CHANGED
@@ -28,6 +28,36 @@ 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
  */
@@ -159,4 +189,6 @@ export interface HpoVisualizerProps {
159
189
  className?: string;
160
190
  /** Additional inline styles */
161
191
  style?: CSSProperties;
192
+ /** Maximum zoom level (default: 5 = 500%) */
193
+ maxZoom?: number;
162
194
  }
package/src/useZoom.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export interface UseZoomOptions {
4
+ /** Minimum zoom level (default: 1 = 100%) */
5
+ minZoom?: number;
6
+ /** Maximum zoom level (default: 5 = 500%) */
7
+ maxZoom?: number;
8
+ /** Zoom step for button controls (default: 0.25 = 25%) */
9
+ zoomStep?: number;
10
+ }
11
+
12
+ export interface UseZoomReturn {
13
+ /** Current zoom level (1 = 100%) */
14
+ zoom: number;
15
+ /** Pan offset in pixels */
16
+ pan: { x: number; y: number };
17
+ /** Increase zoom level */
18
+ zoomIn: () => void;
19
+ /** Decrease zoom level */
20
+ zoomOut: () => void;
21
+ /** Reset zoom and pan to default values */
22
+ resetZoom: () => void;
23
+ /** Handle mouse down for drag start */
24
+ handleMouseDown: (e: React.MouseEvent) => void;
25
+ /** Handle mouse move for dragging */
26
+ handleMouseMove: (e: React.MouseEvent) => void;
27
+ /** Handle mouse up to end dragging */
28
+ handleMouseUp: () => void;
29
+ /** Whether currently dragging */
30
+ isDragging: boolean;
31
+ /** Whether zoom and pan are at default values */
32
+ isDefaultZoom: boolean;
33
+ /** Ref to attach to the container element for wheel event handling */
34
+ containerRef: RefObject<HTMLDivElement | null>;
35
+ }
36
+
37
+ /**
38
+ * Custom hook for zoom and pan functionality
39
+ */
40
+ export function useZoom(options: UseZoomOptions = {}): UseZoomReturn {
41
+ const { minZoom = 1, maxZoom = 5, zoomStep = 0.5 } = options;
42
+
43
+ const [zoom, setZoom] = useState(1);
44
+ const [pan, setPan] = useState({ x: 0, y: 0 });
45
+ const [isDragging, setIsDragging] = useState(false);
46
+ const dragStartRef = useRef({ x: 0, y: 0 });
47
+ const panStartRef = useRef({ x: 0, y: 0 });
48
+ const containerRef = useRef<HTMLDivElement | null>(null);
49
+
50
+ const clampZoom = useCallback(
51
+ (value: number) => Math.max(minZoom, Math.min(maxZoom, value)),
52
+ [minZoom, maxZoom],
53
+ );
54
+
55
+ const zoomIn = useCallback(() => {
56
+ setZoom((prev) => clampZoom(prev + zoomStep));
57
+ }, [clampZoom, zoomStep]);
58
+
59
+ const zoomOut = useCallback(() => {
60
+ setZoom((prev) => clampZoom(prev - zoomStep));
61
+ }, [clampZoom, zoomStep]);
62
+
63
+ const resetZoom = useCallback(() => {
64
+ setZoom(1);
65
+ setPan({ x: 0, y: 0 });
66
+ }, []);
67
+
68
+ // Use native event listener with passive: false to allow preventDefault
69
+ // Use smaller step for wheel (0.1) than buttons (zoomStep) for smoother zooming
70
+ // Zoom is centered on mouse pointer position
71
+ useEffect(() => {
72
+ const container = containerRef.current;
73
+ if (!container) return;
74
+
75
+ const wheelZoomStep = 0.1;
76
+ const handleWheel = (e: WheelEvent) => {
77
+ e.preventDefault();
78
+
79
+ const rect = container.getBoundingClientRect();
80
+ // Mouse position relative to container center
81
+ const mouseX = e.clientX - rect.left - rect.width / 2;
82
+ const mouseY = e.clientY - rect.top - rect.height / 2;
83
+
84
+ const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep;
85
+
86
+ setZoom((prevZoom) => {
87
+ const newZoom = clampZoom(prevZoom + delta);
88
+ if (newZoom === prevZoom) return prevZoom;
89
+
90
+ setPan((prevPan) => {
91
+ // Point under mouse in content space before zoom
92
+ const contentX = (mouseX - prevPan.x) / prevZoom;
93
+ const contentY = (mouseY - prevPan.y) / prevZoom;
94
+
95
+ // New pan to keep the same content point under mouse
96
+ const newPanX = mouseX - contentX * newZoom;
97
+ const newPanY = mouseY - contentY * newZoom;
98
+
99
+ // Clamp to bounds
100
+ if (newZoom <= 1) {
101
+ return { x: 0, y: 0 };
102
+ }
103
+
104
+ const maxPanX = (rect.width * (newZoom - 1)) / 2;
105
+ const maxPanY = (rect.height * (newZoom - 1)) / 2;
106
+
107
+ return {
108
+ x: Math.max(-maxPanX, Math.min(maxPanX, newPanX)),
109
+ y: Math.max(-maxPanY, Math.min(maxPanY, newPanY)),
110
+ };
111
+ });
112
+
113
+ return newZoom;
114
+ });
115
+ };
116
+
117
+ container.addEventListener("wheel", handleWheel, { passive: false });
118
+
119
+ return () => {
120
+ container.removeEventListener("wheel", handleWheel);
121
+ };
122
+ }, [clampZoom]);
123
+
124
+ // Calculate max pan bounds based on zoom level and container size
125
+ const clampPan = useCallback((newPan: { x: number; y: number }, currentZoom: number) => {
126
+ const container = containerRef.current;
127
+ if (!container || currentZoom <= 1) {
128
+ return { x: 0, y: 0 };
129
+ }
130
+
131
+ // Calculate how much extra content is visible when zoomed
132
+ const containerWidth = container.clientWidth;
133
+ const containerHeight = container.clientHeight;
134
+
135
+ // At zoom level Z, the content is Z times larger
136
+ // The max pan is half of the extra size (since content is centered)
137
+ const maxPanX = (containerWidth * (currentZoom - 1)) / 2;
138
+ const maxPanY = (containerHeight * (currentZoom - 1)) / 2;
139
+
140
+ return {
141
+ x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)),
142
+ y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y)),
143
+ };
144
+ }, []);
145
+
146
+ // Recalculate pan bounds when zoom changes
147
+ useEffect(() => {
148
+ setPan((currentPan) => clampPan(currentPan, zoom));
149
+ }, [zoom, clampPan]);
150
+
151
+ const handleMouseDown = useCallback(
152
+ (e: React.MouseEvent) => {
153
+ // Only start drag with left mouse button, and only when zoomed in
154
+ if (e.button !== 0 || zoom <= 1) return;
155
+ setIsDragging(true);
156
+ dragStartRef.current = { x: e.clientX, y: e.clientY };
157
+ panStartRef.current = { ...pan };
158
+ e.preventDefault();
159
+ },
160
+ [pan, zoom],
161
+ );
162
+
163
+ const handleMouseMove = useCallback(
164
+ (e: React.MouseEvent) => {
165
+ if (!isDragging || zoom <= 1) return;
166
+ const dx = e.clientX - dragStartRef.current.x;
167
+ const dy = e.clientY - dragStartRef.current.y;
168
+ const newPan = {
169
+ x: panStartRef.current.x + dx,
170
+ y: panStartRef.current.y + dy,
171
+ };
172
+ setPan(clampPan(newPan, zoom));
173
+ },
174
+ [isDragging, zoom, clampPan],
175
+ );
176
+
177
+ const handleMouseUp = useCallback(() => {
178
+ setIsDragging(false);
179
+ }, []);
180
+
181
+ const isDefaultZoom = zoom === 1 && pan.x === 0 && pan.y === 0;
182
+
183
+ return {
184
+ zoom,
185
+ pan,
186
+ zoomIn,
187
+ zoomOut,
188
+ resetZoom,
189
+ handleMouseDown,
190
+ handleMouseMove,
191
+ handleMouseUp,
192
+ isDragging,
193
+ isDefaultZoom,
194
+ containerRef,
195
+ };
196
+ }