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
package/src/useZoom.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
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
|
+
/** Enable zoom with mouse wheel (default: true) */
|
|
11
|
+
wheelZoom?: boolean;
|
|
12
|
+
/** ViewBox dimensions for coordinate mapping */
|
|
13
|
+
viewBox?: { width: number; height: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseZoomReturn {
|
|
17
|
+
/** Current zoom level (1 = 100%) */
|
|
18
|
+
zoom: number;
|
|
19
|
+
/** Pan offset in pixels */
|
|
20
|
+
pan: { x: number; y: number };
|
|
21
|
+
/** Increase zoom level */
|
|
22
|
+
zoomIn: () => void;
|
|
23
|
+
/** Decrease zoom level */
|
|
24
|
+
zoomOut: () => void;
|
|
25
|
+
/** Reset zoom and pan to default values */
|
|
26
|
+
resetZoom: () => void;
|
|
27
|
+
/** Handle mouse down for drag start */
|
|
28
|
+
handleMouseDown: (e: React.MouseEvent) => void;
|
|
29
|
+
/** Handle mouse move for dragging */
|
|
30
|
+
handleMouseMove: (e: React.MouseEvent) => void;
|
|
31
|
+
/** Handle mouse up to end dragging */
|
|
32
|
+
handleMouseUp: () => void;
|
|
33
|
+
/** Whether currently dragging */
|
|
34
|
+
isDragging: boolean;
|
|
35
|
+
/** Whether zoom and pan are at default values */
|
|
36
|
+
isDefaultZoom: boolean;
|
|
37
|
+
/** Ref to attach to the container element for wheel event handling */
|
|
38
|
+
containerRef: RefObject<SVGSVGElement | null>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ZOOM_ANIMATION_MS = 180;
|
|
42
|
+
|
|
43
|
+
const easeOutCubic = (t: number) => 1 - (1 - t) ** 3;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Custom hook for zoom and pan functionality
|
|
47
|
+
*/
|
|
48
|
+
export function useZoom(options: UseZoomOptions = {}): UseZoomReturn {
|
|
49
|
+
const { minZoom = 1, maxZoom = 5, zoomStep = 0.5, wheelZoom = true, viewBox } = options;
|
|
50
|
+
|
|
51
|
+
const [zoom, setZoom] = useState(1);
|
|
52
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
53
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
54
|
+
const dragStartRef = useRef({ x: 0, y: 0 });
|
|
55
|
+
const panStartRef = useRef({ x: 0, y: 0 });
|
|
56
|
+
const containerRef = useRef<SVGSVGElement | null>(null);
|
|
57
|
+
const zoomRef = useRef(1);
|
|
58
|
+
const panRef = useRef({ x: 0, y: 0 });
|
|
59
|
+
const animationRef = useRef<number | null>(null);
|
|
60
|
+
|
|
61
|
+
const clampZoom = useCallback(
|
|
62
|
+
(value: number) => Math.max(minZoom, Math.min(maxZoom, value)),
|
|
63
|
+
[minZoom, maxZoom],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const stopAnimation = useCallback(() => {
|
|
67
|
+
if (animationRef.current !== null) {
|
|
68
|
+
cancelAnimationFrame(animationRef.current);
|
|
69
|
+
animationRef.current = null;
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const animateZoom = useCallback(
|
|
74
|
+
(
|
|
75
|
+
targetZoom: number,
|
|
76
|
+
options?: { targetPan?: { x: number; y: number }; durationMs?: number },
|
|
77
|
+
) => {
|
|
78
|
+
const { targetPan, durationMs = ZOOM_ANIMATION_MS } = options ?? {};
|
|
79
|
+
stopAnimation();
|
|
80
|
+
|
|
81
|
+
const startZoom = zoomRef.current;
|
|
82
|
+
const clampedTargetZoom = clampZoom(targetZoom);
|
|
83
|
+
const startPan = panRef.current;
|
|
84
|
+
const hasPanTarget = targetPan !== undefined;
|
|
85
|
+
const finalPan = targetPan ?? startPan;
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
durationMs <= 0 ||
|
|
89
|
+
(clampedTargetZoom === startZoom &&
|
|
90
|
+
(!hasPanTarget || (finalPan.x === startPan.x && finalPan.y === startPan.y)))
|
|
91
|
+
) {
|
|
92
|
+
setZoom(clampedTargetZoom);
|
|
93
|
+
zoomRef.current = clampedTargetZoom;
|
|
94
|
+
if (hasPanTarget) {
|
|
95
|
+
setPan(finalPan);
|
|
96
|
+
panRef.current = finalPan;
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const startTime = performance.now();
|
|
102
|
+
const step = (now: number) => {
|
|
103
|
+
const elapsed = now - startTime;
|
|
104
|
+
const t = Math.min(elapsed / durationMs, 1);
|
|
105
|
+
const eased = easeOutCubic(t);
|
|
106
|
+
const nextZoom = startZoom + (clampedTargetZoom - startZoom) * eased;
|
|
107
|
+
zoomRef.current = nextZoom;
|
|
108
|
+
setZoom(nextZoom);
|
|
109
|
+
|
|
110
|
+
if (hasPanTarget) {
|
|
111
|
+
const nextPan = {
|
|
112
|
+
x: startPan.x + (finalPan.x - startPan.x) * eased,
|
|
113
|
+
y: startPan.y + (finalPan.y - startPan.y) * eased,
|
|
114
|
+
};
|
|
115
|
+
panRef.current = nextPan;
|
|
116
|
+
setPan(nextPan);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (t < 1) {
|
|
120
|
+
animationRef.current = requestAnimationFrame(step);
|
|
121
|
+
} else {
|
|
122
|
+
animationRef.current = null;
|
|
123
|
+
setZoom(clampedTargetZoom);
|
|
124
|
+
zoomRef.current = clampedTargetZoom;
|
|
125
|
+
if (hasPanTarget) {
|
|
126
|
+
setPan(finalPan);
|
|
127
|
+
panRef.current = finalPan;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
animationRef.current = requestAnimationFrame(step);
|
|
133
|
+
},
|
|
134
|
+
[clampZoom, stopAnimation],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const zoomIn = useCallback(() => {
|
|
138
|
+
const nextZoom = clampZoom(zoomRef.current + zoomStep);
|
|
139
|
+
animateZoom(nextZoom);
|
|
140
|
+
}, [animateZoom, clampZoom, zoomStep]);
|
|
141
|
+
|
|
142
|
+
const zoomOut = useCallback(() => {
|
|
143
|
+
const nextZoom = clampZoom(zoomRef.current - zoomStep);
|
|
144
|
+
animateZoom(nextZoom);
|
|
145
|
+
}, [animateZoom, clampZoom, zoomStep]);
|
|
146
|
+
|
|
147
|
+
const resetZoom = useCallback(() => {
|
|
148
|
+
animateZoom(1, { targetPan: { x: 0, y: 0 } });
|
|
149
|
+
}, [animateZoom]);
|
|
150
|
+
|
|
151
|
+
const getViewBoxSize = useCallback(
|
|
152
|
+
(container: SVGSVGElement) => {
|
|
153
|
+
if (viewBox?.width && viewBox?.height) {
|
|
154
|
+
return { width: viewBox.width, height: viewBox.height };
|
|
155
|
+
}
|
|
156
|
+
const rect = container.getBoundingClientRect();
|
|
157
|
+
return { width: rect.width || 1, height: rect.height || 1 };
|
|
158
|
+
},
|
|
159
|
+
[viewBox?.width, viewBox?.height],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const getViewBoxMetrics = useCallback(
|
|
163
|
+
(container: SVGSVGElement) => {
|
|
164
|
+
const rect = container.getBoundingClientRect();
|
|
165
|
+
const { width: viewBoxWidth, height: viewBoxHeight } = getViewBoxSize(container);
|
|
166
|
+
const safeWidth = viewBoxWidth || 1;
|
|
167
|
+
const safeHeight = viewBoxHeight || 1;
|
|
168
|
+
const scale =
|
|
169
|
+
rect.width > 0 && rect.height > 0
|
|
170
|
+
? Math.min(rect.width / safeWidth, rect.height / safeHeight)
|
|
171
|
+
: 0;
|
|
172
|
+
const contentWidth = safeWidth * scale;
|
|
173
|
+
const contentHeight = safeHeight * scale;
|
|
174
|
+
const offsetX = (rect.width - contentWidth) / 2;
|
|
175
|
+
const offsetY = (rect.height - contentHeight) / 2;
|
|
176
|
+
|
|
177
|
+
return { rect, viewBoxWidth: safeWidth, viewBoxHeight: safeHeight, scale, offsetX, offsetY };
|
|
178
|
+
},
|
|
179
|
+
[getViewBoxSize],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const getPointerPosition = useCallback(
|
|
183
|
+
(container: SVGSVGElement, clientX: number, clientY: number) => {
|
|
184
|
+
const ctm = container.getScreenCTM();
|
|
185
|
+
if (ctm) {
|
|
186
|
+
if (typeof DOMPoint !== "undefined") {
|
|
187
|
+
const point = new DOMPoint(clientX, clientY);
|
|
188
|
+
const { x, y } = point.matrixTransform(ctm.inverse());
|
|
189
|
+
return { x, y };
|
|
190
|
+
}
|
|
191
|
+
if ("createSVGPoint" in container) {
|
|
192
|
+
const point = container.createSVGPoint();
|
|
193
|
+
point.x = clientX;
|
|
194
|
+
point.y = clientY;
|
|
195
|
+
const { x, y } = point.matrixTransform(ctm.inverse());
|
|
196
|
+
return { x, y };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { rect, scale, offsetX, offsetY } = getViewBoxMetrics(container);
|
|
201
|
+
if (scale <= 0) return null;
|
|
202
|
+
return {
|
|
203
|
+
x: (clientX - rect.left - offsetX) / scale,
|
|
204
|
+
y: (clientY - rect.top - offsetY) / scale,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
[getViewBoxMetrics],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const clampPan = useCallback(
|
|
211
|
+
(
|
|
212
|
+
newPan: { x: number; y: number },
|
|
213
|
+
currentZoom: number,
|
|
214
|
+
viewBoxWidth: number,
|
|
215
|
+
viewBoxHeight: number,
|
|
216
|
+
) => {
|
|
217
|
+
if (currentZoom <= 1) {
|
|
218
|
+
return { x: 0, y: 0 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const maxPanX = (viewBoxWidth * (currentZoom - 1)) / 2;
|
|
222
|
+
const maxPanY = (viewBoxHeight * (currentZoom - 1)) / 2;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)),
|
|
226
|
+
y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y)),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
[],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Use native event listener with passive: false to allow preventDefault
|
|
233
|
+
// Use smaller step for wheel (0.1) than buttons (zoomStep) for smoother zooming
|
|
234
|
+
// Zoom is centered on mouse pointer position
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
zoomRef.current = zoom;
|
|
237
|
+
}, [zoom]);
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
panRef.current = pan;
|
|
241
|
+
}, [pan]);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
return () => stopAnimation();
|
|
245
|
+
}, [stopAnimation]);
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
const container = containerRef.current;
|
|
249
|
+
if (!container || !wheelZoom) return;
|
|
250
|
+
|
|
251
|
+
const wheelZoomStep = 0.1;
|
|
252
|
+
const handleWheel = (e: WheelEvent) => {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
stopAnimation();
|
|
255
|
+
|
|
256
|
+
const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container);
|
|
257
|
+
const pointer = getPointerPosition(container, e.clientX, e.clientY);
|
|
258
|
+
if (!pointer) return;
|
|
259
|
+
|
|
260
|
+
const { x: mouseX, y: mouseY } = pointer;
|
|
261
|
+
const centerX = viewBoxWidth / 2;
|
|
262
|
+
const centerY = viewBoxHeight / 2;
|
|
263
|
+
|
|
264
|
+
const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep;
|
|
265
|
+
const currentZoom = zoomRef.current;
|
|
266
|
+
const nextZoom = clampZoom(currentZoom + delta);
|
|
267
|
+
if (nextZoom === currentZoom) return;
|
|
268
|
+
|
|
269
|
+
const zoomRatio = nextZoom / currentZoom;
|
|
270
|
+
const nextPan = {
|
|
271
|
+
x: (mouseX - centerX) * (1 - zoomRatio) + panRef.current.x * zoomRatio,
|
|
272
|
+
y: (mouseY - centerY) * (1 - zoomRatio) + panRef.current.y * zoomRatio,
|
|
273
|
+
};
|
|
274
|
+
const clampedPan = clampPan(nextPan, nextZoom, viewBoxWidth, viewBoxHeight);
|
|
275
|
+
|
|
276
|
+
zoomRef.current = nextZoom;
|
|
277
|
+
panRef.current = clampedPan;
|
|
278
|
+
setZoom(nextZoom);
|
|
279
|
+
setPan(clampedPan);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
container.addEventListener("wheel", handleWheel, { passive: false });
|
|
283
|
+
|
|
284
|
+
return () => {
|
|
285
|
+
container.removeEventListener("wheel", handleWheel);
|
|
286
|
+
};
|
|
287
|
+
}, [clampZoom, wheelZoom, getViewBoxMetrics, getPointerPosition, clampPan, stopAnimation]);
|
|
288
|
+
|
|
289
|
+
// Recalculate pan bounds when zoom changes
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
const container = containerRef.current;
|
|
292
|
+
if (!container) return;
|
|
293
|
+
const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container);
|
|
294
|
+
setPan((currentPan) => clampPan(currentPan, zoom, viewBoxWidth, viewBoxHeight));
|
|
295
|
+
}, [zoom, clampPan, getViewBoxMetrics]);
|
|
296
|
+
|
|
297
|
+
const handleMouseDown = useCallback(
|
|
298
|
+
(e: React.MouseEvent) => {
|
|
299
|
+
// Only start drag with left mouse button, and only when zoomed in
|
|
300
|
+
if (e.button !== 0 || zoom <= 1) return;
|
|
301
|
+
stopAnimation();
|
|
302
|
+
setIsDragging(true);
|
|
303
|
+
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
|
304
|
+
panStartRef.current = { ...pan };
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
},
|
|
307
|
+
[pan, zoom, stopAnimation],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const handleMouseMove = useCallback(
|
|
311
|
+
(e: React.MouseEvent) => {
|
|
312
|
+
if (!isDragging || zoom <= 1) return;
|
|
313
|
+
const container = containerRef.current;
|
|
314
|
+
if (!container) return;
|
|
315
|
+
const { scale, viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container);
|
|
316
|
+
if (scale <= 0) return;
|
|
317
|
+
const dx = e.clientX - dragStartRef.current.x;
|
|
318
|
+
const dy = e.clientY - dragStartRef.current.y;
|
|
319
|
+
const newPan = {
|
|
320
|
+
x: panStartRef.current.x + dx / scale,
|
|
321
|
+
y: panStartRef.current.y + dy / scale,
|
|
322
|
+
};
|
|
323
|
+
setPan(clampPan(newPan, zoom, viewBoxWidth, viewBoxHeight));
|
|
324
|
+
},
|
|
325
|
+
[isDragging, zoom, clampPan, getViewBoxMetrics],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const handleMouseUp = useCallback(() => {
|
|
329
|
+
setIsDragging(false);
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
const isDefaultZoom = zoom === 1 && pan.x === 0 && pan.y === 0;
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
zoom,
|
|
336
|
+
pan,
|
|
337
|
+
zoomIn,
|
|
338
|
+
zoomOut,
|
|
339
|
+
resetZoom,
|
|
340
|
+
handleMouseDown,
|
|
341
|
+
handleMouseMove,
|
|
342
|
+
handleMouseUp,
|
|
343
|
+
isDragging,
|
|
344
|
+
isDefaultZoom,
|
|
345
|
+
containerRef,
|
|
346
|
+
};
|
|
347
|
+
}
|