hpo-react-visualizer
Advanced tools
| import { describe, expect, it } from "vitest"; | ||
| import { HPO_LABEL_TO_ORGAN, HPO_LABELS, ORGAN_IDS, ORGAN_TO_HPO_LABEL } from "../constants"; | ||
| import type { HPOLabel, OrganId } from "../types"; | ||
| describe("HPO Labels", () => { | ||
| describe("HPO_LABELS", () => { | ||
| it("should contain all 24 HPO labels", () => { | ||
| expect(HPO_LABELS).toHaveLength(24); | ||
| }); | ||
| it("should include 'others' label", () => { | ||
| expect(HPO_LABELS).toContain("others"); | ||
| }); | ||
| }); | ||
| describe("HPO_LABEL_TO_ORGAN mapping", () => { | ||
| it("should map all HPO labels except 'others' to OrganId", () => { | ||
| const labelsWithoutOthers = HPO_LABELS.filter((label) => label !== "others"); | ||
| expect(Object.keys(HPO_LABEL_TO_ORGAN)).toHaveLength(labelsWithoutOthers.length); | ||
| }); | ||
| it("should map each HPO label to a valid OrganId", () => { | ||
| for (const [_label, organId] of Object.entries(HPO_LABEL_TO_ORGAN)) { | ||
| expect(ORGAN_IDS).toContain(organId); | ||
| } | ||
| }); | ||
| it("should correctly map specific HPO labels", () => { | ||
| expect(HPO_LABEL_TO_ORGAN["Abnormality of the digestive system"]).toBe("digestive"); | ||
| expect(HPO_LABEL_TO_ORGAN["Growth abnormality"]).toBe("growth"); | ||
| expect(HPO_LABEL_TO_ORGAN["Abnormality of the eye"]).toBe("eye"); | ||
| }); | ||
| }); | ||
| describe("ORGAN_TO_HPO_LABEL mapping", () => { | ||
| it("should map all OrganIds to HPO labels", () => { | ||
| expect(Object.keys(ORGAN_TO_HPO_LABEL)).toHaveLength(ORGAN_IDS.length); | ||
| }); | ||
| it("should map each OrganId to a valid HPO label", () => { | ||
| for (const [_organId, label] of Object.entries(ORGAN_TO_HPO_LABEL)) { | ||
| expect(HPO_LABELS).toContain(label); | ||
| } | ||
| }); | ||
| it("should correctly map specific OrganIds", () => { | ||
| expect(ORGAN_TO_HPO_LABEL.digestive).toBe("Abnormality of the digestive system"); | ||
| expect(ORGAN_TO_HPO_LABEL.growth).toBe("Growth abnormality"); | ||
| expect(ORGAN_TO_HPO_LABEL.eye).toBe("Abnormality of the eye"); | ||
| }); | ||
| }); | ||
| describe("Bidirectional mapping consistency", () => { | ||
| it("should have consistent bidirectional mapping", () => { | ||
| // HPO_LABEL_TO_ORGAN → ORGAN_TO_HPO_LABEL should return original label | ||
| for (const [label, organId] of Object.entries(HPO_LABEL_TO_ORGAN)) { | ||
| const reverseLabel = ORGAN_TO_HPO_LABEL[organId as OrganId]; | ||
| expect(reverseLabel).toBe(label); | ||
| } | ||
| }); | ||
| it("should have consistent reverse mapping", () => { | ||
| // ORGAN_TO_HPO_LABEL → HPO_LABEL_TO_ORGAN should return original organId | ||
| for (const [organId, label] of Object.entries(ORGAN_TO_HPO_LABEL)) { | ||
| const reverseOrganId = HPO_LABEL_TO_ORGAN[label as Exclude<HPOLabel, "others">]; | ||
| expect(reverseOrganId).toBe(organId); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
| import { act, renderHook } from "@testing-library/react"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { useZoom } from "../useZoom"; | ||
| describe("useZoom hook", () => { | ||
| it("should initialize with default values", () => { | ||
| const { result } = renderHook(() => useZoom()); | ||
| expect(result.current.zoom).toBe(1); | ||
| expect(result.current.pan).toEqual({ x: 0, y: 0 }); | ||
| expect(result.current.isDefaultZoom).toBe(true); | ||
| expect(result.current.isDragging).toBe(false); | ||
| }); | ||
| it("should increase zoom when zoomIn is called", () => { | ||
| const { result } = renderHook(() => useZoom()); | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1.5); | ||
| expect(result.current.isDefaultZoom).toBe(false); | ||
| }); | ||
| it("should decrease zoom when zoomOut is called", () => { | ||
| const { result } = renderHook(() => useZoom()); | ||
| // First zoom in to have room to zoom out | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.zoom).toBe(2); | ||
| act(() => { | ||
| result.current.zoomOut(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1.5); | ||
| }); | ||
| it("should not go below minZoom", () => { | ||
| const { result } = renderHook(() => useZoom({ minZoom: 1 })); | ||
| act(() => { | ||
| result.current.zoomOut(); | ||
| result.current.zoomOut(); | ||
| result.current.zoomOut(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1); | ||
| }); | ||
| it("should not go above maxZoom", () => { | ||
| const { result } = renderHook(() => useZoom({ maxZoom: 2 })); | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| result.current.zoomIn(); | ||
| result.current.zoomIn(); | ||
| result.current.zoomIn(); | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.zoom).toBe(2); | ||
| }); | ||
| it("should reset zoom and pan when resetZoom is called", () => { | ||
| const { result } = renderHook(() => useZoom()); | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.zoom).toBe(2); | ||
| act(() => { | ||
| result.current.resetZoom(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1); | ||
| expect(result.current.pan).toEqual({ x: 0, y: 0 }); | ||
| expect(result.current.isDefaultZoom).toBe(true); | ||
| }); | ||
| it("should use custom zoomStep", () => { | ||
| const { result } = renderHook(() => useZoom({ zoomStep: 0.5 })); | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1.5); | ||
| }); | ||
| it("should return isDefaultZoom as false when zoomed", () => { | ||
| const { result } = renderHook(() => useZoom()); | ||
| expect(result.current.isDefaultZoom).toBe(true); | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.isDefaultZoom).toBe(false); | ||
| }); | ||
| }); |
| import { fireEvent, render } from "@testing-library/react"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { ZoomControls } from "../ZoomControls"; | ||
| describe("ZoomControls component", () => { | ||
| const defaultProps = { | ||
| onZoomIn: vi.fn(), | ||
| onZoomOut: vi.fn(), | ||
| onReset: vi.fn(), | ||
| showResetButton: false, | ||
| zoom: 1, | ||
| minZoom: 1, | ||
| maxZoom: 5, | ||
| isVisible: true, | ||
| }; | ||
| it("should render zoom in and zoom out buttons", () => { | ||
| const { getByLabelText } = render(<ZoomControls {...defaultProps} />); | ||
| expect(getByLabelText("Zoom in")).toBeDefined(); | ||
| expect(getByLabelText("Zoom out")).toBeDefined(); | ||
| }); | ||
| it("should call onZoomIn when + button is clicked", () => { | ||
| const onZoomIn = vi.fn(); | ||
| const { getByLabelText } = render(<ZoomControls {...defaultProps} onZoomIn={onZoomIn} />); | ||
| fireEvent.click(getByLabelText("Zoom in")); | ||
| expect(onZoomIn).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it("should call onZoomOut when - button is clicked", () => { | ||
| const onZoomOut = vi.fn(); | ||
| const { getByLabelText } = render( | ||
| <ZoomControls {...defaultProps} zoom={2} onZoomOut={onZoomOut} />, | ||
| ); | ||
| fireEvent.click(getByLabelText("Zoom out")); | ||
| expect(onZoomOut).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it("should disable zoom in button when at maxZoom", () => { | ||
| const onZoomIn = vi.fn(); | ||
| const { getByLabelText } = render( | ||
| <ZoomControls {...defaultProps} zoom={5} maxZoom={5} onZoomIn={onZoomIn} />, | ||
| ); | ||
| const zoomInButton = getByLabelText("Zoom in") as HTMLButtonElement; | ||
| expect(zoomInButton.disabled).toBe(true); | ||
| fireEvent.click(zoomInButton); | ||
| expect(onZoomIn).not.toHaveBeenCalled(); | ||
| }); | ||
| it("should disable zoom out button when at minZoom", () => { | ||
| const onZoomOut = vi.fn(); | ||
| const { getByLabelText } = render( | ||
| <ZoomControls {...defaultProps} zoom={1} minZoom={1} onZoomOut={onZoomOut} />, | ||
| ); | ||
| const zoomOutButton = getByLabelText("Zoom out") as HTMLButtonElement; | ||
| expect(zoomOutButton.disabled).toBe(true); | ||
| fireEvent.click(zoomOutButton); | ||
| expect(onZoomOut).not.toHaveBeenCalled(); | ||
| }); | ||
| it("should hide reset button when showResetButton is false", () => { | ||
| const { getByLabelText } = render(<ZoomControls {...defaultProps} showResetButton={false} />); | ||
| const resetButton = getByLabelText("Reset zoom"); | ||
| const container = resetButton.parentElement as HTMLElement; | ||
| expect(container.style.opacity).toBe("0"); | ||
| expect(container.style.pointerEvents).toBe("none"); | ||
| }); | ||
| it("should render reset button when showResetButton is true", () => { | ||
| const { getByLabelText } = render( | ||
| <ZoomControls {...defaultProps} showResetButton={true} zoom={2} />, | ||
| ); | ||
| expect(getByLabelText("Reset zoom")).toBeDefined(); | ||
| }); | ||
| it("should call onReset when reset button is clicked", () => { | ||
| const onReset = vi.fn(); | ||
| const { getByLabelText } = render( | ||
| <ZoomControls {...defaultProps} showResetButton={true} zoom={2} onReset={onReset} />, | ||
| ); | ||
| fireEvent.click(getByLabelText("Reset zoom")); | ||
| expect(onReset).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it("should display reset icon in reset button", () => { | ||
| const { getByLabelText } = render( | ||
| <ZoomControls {...defaultProps} showResetButton={true} zoom={1.5} />, | ||
| ); | ||
| const resetButton = getByLabelText("Reset zoom"); | ||
| expect(resetButton.textContent).toContain("↺"); | ||
| }); | ||
| }); |
+196
| import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; | ||
| export interface UseZoomOptions { | ||
| /** Minimum zoom level (default: 1 = 100%) */ | ||
| minZoom?: number; | ||
| /** Maximum zoom level (default: 5 = 500%) */ | ||
| maxZoom?: number; | ||
| /** Zoom step for button controls (default: 0.25 = 25%) */ | ||
| zoomStep?: number; | ||
| } | ||
| export interface UseZoomReturn { | ||
| /** Current zoom level (1 = 100%) */ | ||
| zoom: number; | ||
| /** Pan offset in pixels */ | ||
| pan: { x: number; y: number }; | ||
| /** Increase zoom level */ | ||
| zoomIn: () => void; | ||
| /** Decrease zoom level */ | ||
| zoomOut: () => void; | ||
| /** Reset zoom and pan to default values */ | ||
| resetZoom: () => void; | ||
| /** Handle mouse down for drag start */ | ||
| handleMouseDown: (e: React.MouseEvent) => void; | ||
| /** Handle mouse move for dragging */ | ||
| handleMouseMove: (e: React.MouseEvent) => void; | ||
| /** Handle mouse up to end dragging */ | ||
| handleMouseUp: () => void; | ||
| /** Whether currently dragging */ | ||
| isDragging: boolean; | ||
| /** Whether zoom and pan are at default values */ | ||
| isDefaultZoom: boolean; | ||
| /** Ref to attach to the container element for wheel event handling */ | ||
| containerRef: RefObject<HTMLDivElement | null>; | ||
| } | ||
| /** | ||
| * Custom hook for zoom and pan functionality | ||
| */ | ||
| export function useZoom(options: UseZoomOptions = {}): UseZoomReturn { | ||
| const { minZoom = 1, maxZoom = 5, zoomStep = 0.5 } = options; | ||
| const [zoom, setZoom] = useState(1); | ||
| const [pan, setPan] = useState({ x: 0, y: 0 }); | ||
| const [isDragging, setIsDragging] = useState(false); | ||
| const dragStartRef = useRef({ x: 0, y: 0 }); | ||
| const panStartRef = useRef({ x: 0, y: 0 }); | ||
| const containerRef = useRef<HTMLDivElement | null>(null); | ||
| const clampZoom = useCallback( | ||
| (value: number) => Math.max(minZoom, Math.min(maxZoom, value)), | ||
| [minZoom, maxZoom], | ||
| ); | ||
| const zoomIn = useCallback(() => { | ||
| setZoom((prev) => clampZoom(prev + zoomStep)); | ||
| }, [clampZoom, zoomStep]); | ||
| const zoomOut = useCallback(() => { | ||
| setZoom((prev) => clampZoom(prev - zoomStep)); | ||
| }, [clampZoom, zoomStep]); | ||
| const resetZoom = useCallback(() => { | ||
| setZoom(1); | ||
| setPan({ x: 0, y: 0 }); | ||
| }, []); | ||
| // Use native event listener with passive: false to allow preventDefault | ||
| // Use smaller step for wheel (0.1) than buttons (zoomStep) for smoother zooming | ||
| // Zoom is centered on mouse pointer position | ||
| useEffect(() => { | ||
| const container = containerRef.current; | ||
| if (!container) return; | ||
| const wheelZoomStep = 0.1; | ||
| const handleWheel = (e: WheelEvent) => { | ||
| e.preventDefault(); | ||
| const rect = container.getBoundingClientRect(); | ||
| // Mouse position relative to container center | ||
| const mouseX = e.clientX - rect.left - rect.width / 2; | ||
| const mouseY = e.clientY - rect.top - rect.height / 2; | ||
| const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep; | ||
| setZoom((prevZoom) => { | ||
| const newZoom = clampZoom(prevZoom + delta); | ||
| if (newZoom === prevZoom) return prevZoom; | ||
| setPan((prevPan) => { | ||
| // Point under mouse in content space before zoom | ||
| const contentX = (mouseX - prevPan.x) / prevZoom; | ||
| const contentY = (mouseY - prevPan.y) / prevZoom; | ||
| // New pan to keep the same content point under mouse | ||
| const newPanX = mouseX - contentX * newZoom; | ||
| const newPanY = mouseY - contentY * newZoom; | ||
| // Clamp to bounds | ||
| if (newZoom <= 1) { | ||
| return { x: 0, y: 0 }; | ||
| } | ||
| const maxPanX = (rect.width * (newZoom - 1)) / 2; | ||
| const maxPanY = (rect.height * (newZoom - 1)) / 2; | ||
| return { | ||
| x: Math.max(-maxPanX, Math.min(maxPanX, newPanX)), | ||
| y: Math.max(-maxPanY, Math.min(maxPanY, newPanY)), | ||
| }; | ||
| }); | ||
| return newZoom; | ||
| }); | ||
| }; | ||
| container.addEventListener("wheel", handleWheel, { passive: false }); | ||
| return () => { | ||
| container.removeEventListener("wheel", handleWheel); | ||
| }; | ||
| }, [clampZoom]); | ||
| // Calculate max pan bounds based on zoom level and container size | ||
| const clampPan = useCallback((newPan: { x: number; y: number }, currentZoom: number) => { | ||
| const container = containerRef.current; | ||
| if (!container || currentZoom <= 1) { | ||
| return { x: 0, y: 0 }; | ||
| } | ||
| // Calculate how much extra content is visible when zoomed | ||
| const containerWidth = container.clientWidth; | ||
| const containerHeight = container.clientHeight; | ||
| // At zoom level Z, the content is Z times larger | ||
| // The max pan is half of the extra size (since content is centered) | ||
| const maxPanX = (containerWidth * (currentZoom - 1)) / 2; | ||
| const maxPanY = (containerHeight * (currentZoom - 1)) / 2; | ||
| return { | ||
| x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)), | ||
| y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y)), | ||
| }; | ||
| }, []); | ||
| // Recalculate pan bounds when zoom changes | ||
| useEffect(() => { | ||
| setPan((currentPan) => clampPan(currentPan, zoom)); | ||
| }, [zoom, clampPan]); | ||
| const handleMouseDown = useCallback( | ||
| (e: React.MouseEvent) => { | ||
| // Only start drag with left mouse button, and only when zoomed in | ||
| if (e.button !== 0 || zoom <= 1) return; | ||
| setIsDragging(true); | ||
| dragStartRef.current = { x: e.clientX, y: e.clientY }; | ||
| panStartRef.current = { ...pan }; | ||
| e.preventDefault(); | ||
| }, | ||
| [pan, zoom], | ||
| ); | ||
| const handleMouseMove = useCallback( | ||
| (e: React.MouseEvent) => { | ||
| if (!isDragging || zoom <= 1) return; | ||
| const dx = e.clientX - dragStartRef.current.x; | ||
| const dy = e.clientY - dragStartRef.current.y; | ||
| const newPan = { | ||
| x: panStartRef.current.x + dx, | ||
| y: panStartRef.current.y + dy, | ||
| }; | ||
| setPan(clampPan(newPan, zoom)); | ||
| }, | ||
| [isDragging, zoom, clampPan], | ||
| ); | ||
| const handleMouseUp = useCallback(() => { | ||
| setIsDragging(false); | ||
| }, []); | ||
| const isDefaultZoom = zoom === 1 && pan.x === 0 && pan.y === 0; | ||
| return { | ||
| zoom, | ||
| pan, | ||
| zoomIn, | ||
| zoomOut, | ||
| resetZoom, | ||
| handleMouseDown, | ||
| handleMouseMove, | ||
| handleMouseUp, | ||
| isDragging, | ||
| isDefaultZoom, | ||
| containerRef, | ||
| }; | ||
| } |
| import type { CSSProperties } from "react"; | ||
| interface ZoomControlsProps { | ||
| /** Callback when zoom in button is clicked */ | ||
| onZoomIn: () => void; | ||
| /** Callback when zoom out button is clicked */ | ||
| onZoomOut: () => void; | ||
| /** Callback when reset button is clicked */ | ||
| onReset: () => void; | ||
| /** Whether to show the reset button */ | ||
| showResetButton: boolean; | ||
| /** Current zoom level (1 = 100%) */ | ||
| zoom: number; | ||
| /** Minimum zoom level */ | ||
| minZoom: number; | ||
| /** Maximum zoom level */ | ||
| maxZoom: number; | ||
| /** Whether the controls are visible (for fade effect) */ | ||
| isVisible: boolean; | ||
| } | ||
| const buttonBaseStyle: CSSProperties = { | ||
| width: 32, | ||
| height: 32, | ||
| display: "flex", | ||
| alignItems: "center", | ||
| justifyContent: "center", | ||
| backgroundColor: "rgba(255, 255, 255, 0.9)", | ||
| border: "1px solid #d1d5db", | ||
| borderRadius: 6, | ||
| cursor: "pointer", | ||
| fontSize: 18, | ||
| fontWeight: "bold", | ||
| color: "#374151", | ||
| transition: "background-color 0.15s ease", | ||
| userSelect: "none", | ||
| }; | ||
| const buttonDisabledStyle: CSSProperties = { | ||
| ...buttonBaseStyle, | ||
| opacity: 0.4, | ||
| cursor: "not-allowed", | ||
| }; | ||
| const getContainerStyle = (isVisible: boolean): CSSProperties => ({ | ||
| position: "absolute", | ||
| bottom: 12, | ||
| right: 12, | ||
| display: "flex", | ||
| flexDirection: "column", | ||
| gap: 4, | ||
| zIndex: 10, | ||
| opacity: isVisible ? 1 : 0, | ||
| transition: "opacity 0.2s ease-in-out", | ||
| pointerEvents: isVisible ? "auto" : "none", | ||
| }); | ||
| const getResetContainerStyle = (isVisible: boolean): CSSProperties => ({ | ||
| position: "absolute", | ||
| bottom: 12, | ||
| left: 12, | ||
| zIndex: 10, | ||
| opacity: isVisible ? 1 : 0, | ||
| transition: "opacity 0.2s ease-in-out", | ||
| pointerEvents: isVisible ? "auto" : "none", | ||
| }); | ||
| const resetButtonStyle: CSSProperties = { | ||
| ...buttonBaseStyle, | ||
| width: "auto", | ||
| padding: "6px 12px", | ||
| fontSize: 12, | ||
| fontWeight: 500, | ||
| }; | ||
| /** | ||
| * Zoom control buttons component | ||
| * Displays +/- buttons in bottom right, reset button in bottom left | ||
| */ | ||
| export function ZoomControls({ | ||
| onZoomIn, | ||
| onZoomOut, | ||
| onReset, | ||
| showResetButton, | ||
| zoom, | ||
| minZoom, | ||
| maxZoom, | ||
| isVisible, | ||
| }: ZoomControlsProps) { | ||
| const isMinZoom = zoom <= minZoom; | ||
| const isMaxZoom = zoom >= maxZoom; | ||
| return ( | ||
| <> | ||
| {/* Zoom in/out buttons - bottom right */} | ||
| <div style={getContainerStyle(isVisible)}> | ||
| <button | ||
| type="button" | ||
| onClick={onZoomIn} | ||
| disabled={isMaxZoom} | ||
| style={isMaxZoom ? buttonDisabledStyle : buttonBaseStyle} | ||
| aria-label="Zoom in" | ||
| > | ||
| + | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={onZoomOut} | ||
| disabled={isMinZoom} | ||
| style={isMinZoom ? buttonDisabledStyle : buttonBaseStyle} | ||
| aria-label="Zoom out" | ||
| > | ||
| − | ||
| </button> | ||
| </div> | ||
| {/* Reset button - bottom left (only shown when not at default zoom) */} | ||
| <div style={getResetContainerStyle(isVisible && showResetButton)}> | ||
| <button type="button" onClick={onReset} style={resetButtonStyle} aria-label="Reset zoom"> | ||
| ↺ | ||
| </button> | ||
| </div> | ||
| </> | ||
| ); | ||
| } |
+23
-2
@@ -9,2 +9,7 @@ import { CSSProperties, ComponentType } from 'react'; | ||
| /** | ||
| * HPO official category labels | ||
| * @see https://hpo.jax.org/ | ||
| */ | ||
| type HPOLabel = "others" | "Growth abnormality" | "Abnormality of the genitourinary system" | "Abnormality of the immune system" | "Abnormality of the digestive system" | "Abnormality of metabolism/homeostasis" | "Abnormality of head or neck" | "Abnormality of the musculoskeletal system" | "Abnormality of the nervous system" | "Abnormality of the respiratory system" | "Abnormality of the eye" | "Abnormality of the cardiovascular system" | "Abnormality of the ear" | "Abnormality of prenatal development or birth" | "Abnormality of the integument" | "Abnormality of the breast" | "Abnormality of the endocrine system" | "Abnormality of blood and blood-forming tissues" | "Abnormality of limbs" | "Abnormality of the voice" | "Constitutional symptom" | "Neoplasm" | "Abnormal cellular phenotype" | "Abnormality of the thoracic cavity"; | ||
| /** | ||
| * Color scheme for each organ. | ||
@@ -133,2 +138,4 @@ */ | ||
| style?: CSSProperties; | ||
| /** Maximum zoom level (default: 5 = 500%) */ | ||
| maxZoom?: number; | ||
| } | ||
@@ -149,2 +156,15 @@ | ||
| /** | ||
| * All HPO category labels | ||
| */ | ||
| declare const HPO_LABELS: readonly HPOLabel[]; | ||
| /** | ||
| * Mapping from HPO label to OrganId | ||
| * Note: "others" has no corresponding OrganId | ||
| */ | ||
| declare const HPO_LABEL_TO_ORGAN: Record<Exclude<HPOLabel, "others">, OrganId>; | ||
| /** | ||
| * Mapping from OrganId to HPO label | ||
| */ | ||
| declare const ORGAN_TO_HPO_LABEL: Record<OrganId, Exclude<HPOLabel, "others">>; | ||
| /** | ||
| * Color palettes for each color scheme. | ||
@@ -168,4 +188,5 @@ * Based on Tailwind CSS color palette. | ||
| * - Exposes hover and selection state via callbacks | ||
| * - Zoom and pan support with mouse wheel and drag | ||
| */ | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, maxZoom, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
@@ -248,2 +269,2 @@ interface OrganSvgWrapperProps { | ||
| export { ANIMATION_DURATION_MS, type ColorScale as ColorPalette, type ColorName as ColorScheme, DEFAULT_COLOR_PALETTE, HpoVisualizer, type HpoVisualizerProps, ORGAN_COMPONENTS, ORGAN_IDS, ORGAN_NAMES_EN, ORGAN_NAMES_KO, type OrganConfig, type OrganId, type OrganInteractionHandlers, type OrganInteractionState, type OrganStyle, OrganSvg, type OrganSvgProps, type OrganSvgWrapperProps, type UseOrganInteractionOptions, type UseOrganInteractionResult, useOrganInteraction }; | ||
| export { ANIMATION_DURATION_MS, type ColorScale as ColorPalette, type ColorName as ColorScheme, DEFAULT_COLOR_PALETTE, type HPOLabel, HPO_LABELS, HPO_LABEL_TO_ORGAN, HpoVisualizer, type HpoVisualizerProps, ORGAN_COMPONENTS, ORGAN_IDS, ORGAN_NAMES_EN, ORGAN_NAMES_KO, ORGAN_TO_HPO_LABEL, type OrganConfig, type OrganId, type OrganInteractionHandlers, type OrganInteractionState, type OrganStyle, OrganSvg, type OrganSvgProps, type OrganSvgWrapperProps, type UseOrganInteractionOptions, type UseOrganInteractionResult, useOrganInteraction }; |
+23
-2
@@ -9,2 +9,7 @@ import { CSSProperties, ComponentType } from 'react'; | ||
| /** | ||
| * HPO official category labels | ||
| * @see https://hpo.jax.org/ | ||
| */ | ||
| type HPOLabel = "others" | "Growth abnormality" | "Abnormality of the genitourinary system" | "Abnormality of the immune system" | "Abnormality of the digestive system" | "Abnormality of metabolism/homeostasis" | "Abnormality of head or neck" | "Abnormality of the musculoskeletal system" | "Abnormality of the nervous system" | "Abnormality of the respiratory system" | "Abnormality of the eye" | "Abnormality of the cardiovascular system" | "Abnormality of the ear" | "Abnormality of prenatal development or birth" | "Abnormality of the integument" | "Abnormality of the breast" | "Abnormality of the endocrine system" | "Abnormality of blood and blood-forming tissues" | "Abnormality of limbs" | "Abnormality of the voice" | "Constitutional symptom" | "Neoplasm" | "Abnormal cellular phenotype" | "Abnormality of the thoracic cavity"; | ||
| /** | ||
| * Color scheme for each organ. | ||
@@ -133,2 +138,4 @@ */ | ||
| style?: CSSProperties; | ||
| /** Maximum zoom level (default: 5 = 500%) */ | ||
| maxZoom?: number; | ||
| } | ||
@@ -149,2 +156,15 @@ | ||
| /** | ||
| * All HPO category labels | ||
| */ | ||
| declare const HPO_LABELS: readonly HPOLabel[]; | ||
| /** | ||
| * Mapping from HPO label to OrganId | ||
| * Note: "others" has no corresponding OrganId | ||
| */ | ||
| declare const HPO_LABEL_TO_ORGAN: Record<Exclude<HPOLabel, "others">, OrganId>; | ||
| /** | ||
| * Mapping from OrganId to HPO label | ||
| */ | ||
| declare const ORGAN_TO_HPO_LABEL: Record<OrganId, Exclude<HPOLabel, "others">>; | ||
| /** | ||
| * Color palettes for each color scheme. | ||
@@ -168,4 +188,5 @@ * Based on Tailwind CSS color palette. | ||
| * - Exposes hover and selection state via callbacks | ||
| * - Zoom and pan support with mouse wheel and drag | ||
| */ | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, maxZoom, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
@@ -248,2 +269,2 @@ interface OrganSvgWrapperProps { | ||
| export { ANIMATION_DURATION_MS, type ColorScale as ColorPalette, type ColorName as ColorScheme, DEFAULT_COLOR_PALETTE, HpoVisualizer, type HpoVisualizerProps, ORGAN_COMPONENTS, ORGAN_IDS, ORGAN_NAMES_EN, ORGAN_NAMES_KO, type OrganConfig, type OrganId, type OrganInteractionHandlers, type OrganInteractionState, type OrganStyle, OrganSvg, type OrganSvgProps, type OrganSvgWrapperProps, type UseOrganInteractionOptions, type UseOrganInteractionResult, useOrganInteraction }; | ||
| export { ANIMATION_DURATION_MS, type ColorScale as ColorPalette, type ColorName as ColorScheme, DEFAULT_COLOR_PALETTE, type HPOLabel, HPO_LABELS, HPO_LABEL_TO_ORGAN, HpoVisualizer, type HpoVisualizerProps, ORGAN_COMPONENTS, ORGAN_IDS, ORGAN_NAMES_EN, ORGAN_NAMES_KO, ORGAN_TO_HPO_LABEL, type OrganConfig, type OrganId, type OrganInteractionHandlers, type OrganInteractionState, type OrganStyle, OrganSvg, type OrganSvgProps, type OrganSvgWrapperProps, type UseOrganInteractionOptions, type UseOrganInteractionResult, useOrganInteraction }; |
+3
-3
| { | ||
| "name": "hpo-react-visualizer", | ||
| "version": "0.0.1", | ||
| "version": "0.0.2", | ||
| "description": "Interactive Human Phenotype Ontology (HPO) organ visualization library", | ||
@@ -45,4 +45,4 @@ "private": false, | ||
| "peerDependencies": { | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0" | ||
| "react": "^18.0.0 || ^19.0.0", | ||
| "react-dom": "^18.0.0 || ^19.0.0" | ||
| }, | ||
@@ -49,0 +49,0 @@ "devDependencies": { |
+90
-1
@@ -1,2 +0,2 @@ | ||
| import type { ColorName, OrganId, StrictColorPalette } from "./types"; | ||
| import type { ColorName, HPOLabel, OrganId, StrictColorPalette } from "./types"; | ||
@@ -91,2 +91,91 @@ /** | ||
| /** | ||
| * All HPO category labels | ||
| */ | ||
| export const HPO_LABELS: readonly HPOLabel[] = [ | ||
| "others", | ||
| "Growth abnormality", | ||
| "Abnormality of the genitourinary system", | ||
| "Abnormality of the immune system", | ||
| "Abnormality of the digestive system", | ||
| "Abnormality of metabolism/homeostasis", | ||
| "Abnormality of head or neck", | ||
| "Abnormality of the musculoskeletal system", | ||
| "Abnormality of the nervous system", | ||
| "Abnormality of the respiratory system", | ||
| "Abnormality of the eye", | ||
| "Abnormality of the cardiovascular system", | ||
| "Abnormality of the ear", | ||
| "Abnormality of prenatal development or birth", | ||
| "Abnormality of the integument", | ||
| "Abnormality of the breast", | ||
| "Abnormality of the endocrine system", | ||
| "Abnormality of blood and blood-forming tissues", | ||
| "Abnormality of limbs", | ||
| "Abnormality of the voice", | ||
| "Constitutional symptom", | ||
| "Neoplasm", | ||
| "Abnormal cellular phenotype", | ||
| "Abnormality of the thoracic cavity", | ||
| ] as const; | ||
| /** | ||
| * Mapping from HPO label to OrganId | ||
| * Note: "others" has no corresponding OrganId | ||
| */ | ||
| export const HPO_LABEL_TO_ORGAN: Record<Exclude<HPOLabel, "others">, OrganId> = { | ||
| "Growth abnormality": "growth", | ||
| "Abnormality of the genitourinary system": "kidney", | ||
| "Abnormality of the immune system": "immune", | ||
| "Abnormality of the digestive system": "digestive", | ||
| "Abnormality of metabolism/homeostasis": "metabolism", | ||
| "Abnormality of head or neck": "head", | ||
| "Abnormality of the musculoskeletal system": "muscle", | ||
| "Abnormality of the nervous system": "nervous", | ||
| "Abnormality of the respiratory system": "lung", | ||
| "Abnormality of the eye": "eye", | ||
| "Abnormality of the cardiovascular system": "heart", | ||
| "Abnormality of the ear": "ear", | ||
| "Abnormality of prenatal development or birth": "prenatal", | ||
| "Abnormality of the integument": "integument", | ||
| "Abnormality of the breast": "breast", | ||
| "Abnormality of the endocrine system": "endocrine", | ||
| "Abnormality of blood and blood-forming tissues": "blood", | ||
| "Abnormality of limbs": "limbs", | ||
| "Abnormality of the voice": "voice", | ||
| "Constitutional symptom": "constitutional", | ||
| Neoplasm: "neoplasm", | ||
| "Abnormal cellular phenotype": "cell", | ||
| "Abnormality of the thoracic cavity": "thoracicCavity", | ||
| }; | ||
| /** | ||
| * Mapping from OrganId to HPO label | ||
| */ | ||
| export const ORGAN_TO_HPO_LABEL: Record<OrganId, Exclude<HPOLabel, "others">> = { | ||
| growth: "Growth abnormality", | ||
| kidney: "Abnormality of the genitourinary system", | ||
| immune: "Abnormality of the immune system", | ||
| digestive: "Abnormality of the digestive system", | ||
| metabolism: "Abnormality of metabolism/homeostasis", | ||
| head: "Abnormality of head or neck", | ||
| muscle: "Abnormality of the musculoskeletal system", | ||
| nervous: "Abnormality of the nervous system", | ||
| lung: "Abnormality of the respiratory system", | ||
| eye: "Abnormality of the eye", | ||
| heart: "Abnormality of the cardiovascular system", | ||
| ear: "Abnormality of the ear", | ||
| prenatal: "Abnormality of prenatal development or birth", | ||
| integument: "Abnormality of the integument", | ||
| breast: "Abnormality of the breast", | ||
| endocrine: "Abnormality of the endocrine system", | ||
| blood: "Abnormality of blood and blood-forming tissues", | ||
| limbs: "Abnormality of limbs", | ||
| voice: "Abnormality of the voice", | ||
| constitutional: "Constitutional symptom", | ||
| neoplasm: "Neoplasm", | ||
| cell: "Abnormal cellular phenotype", | ||
| thoracicCavity: "Abnormality of the thoracic cavity", | ||
| }; | ||
| /** | ||
| * Color palettes for each color scheme. | ||
@@ -93,0 +182,0 @@ * Based on Tailwind CSS color palette. |
+88
-22
@@ -1,2 +0,2 @@ | ||
| import { type CSSProperties, useId, useMemo } from "react"; | ||
| import { type CSSProperties, useId, useMemo, useState } from "react"; | ||
| import { BODY_VIEWBOX, DEFAULT_COLOR_NAME, ORGAN_IDS } from "./constants"; | ||
@@ -8,2 +8,4 @@ import { createStrictColorPalette } from "./lib"; | ||
| import { useOrganInteraction } from "./useOrganInteraction"; | ||
| import { useZoom } from "./useZoom"; | ||
| import { ZoomControls } from "./ZoomControls"; | ||
@@ -70,2 +72,4 @@ /** | ||
| const MIN_ZOOM = 1; | ||
| /** | ||
@@ -80,2 +84,3 @@ * HPO Visualizer - Interactive human organ visualization component | ||
| * - Exposes hover and selection state via callbacks | ||
| * - Zoom and pan support with mouse wheel and drag | ||
| */ | ||
@@ -94,5 +99,22 @@ export function HpoVisualizer({ | ||
| style, | ||
| maxZoom = 5, | ||
| }: HpoVisualizerProps) { | ||
| const visualizerID = useId(); | ||
| const [isHovering, setIsHovering] = useState(false); | ||
| // Zoom and pan functionality | ||
| const { | ||
| zoom, | ||
| pan, | ||
| zoomIn, | ||
| zoomOut, | ||
| resetZoom, | ||
| handleMouseDown, | ||
| handleMouseMove, | ||
| handleMouseUp, | ||
| isDragging, | ||
| isDefaultZoom, | ||
| containerRef, | ||
| } = useZoom({ minZoom: MIN_ZOOM, maxZoom }); | ||
| // Create strict color palette with all required colors and alpha values | ||
@@ -147,4 +169,17 @@ const colorPalette: StrictColorPalette = useMemo( | ||
| ...style, | ||
| // Apply overflow after style spread to ensure it takes precedence | ||
| // Use 'clip' instead of 'hidden' to respect padding | ||
| overflow: "clip", | ||
| }; | ||
| const contentStyle: CSSProperties = { | ||
| position: "relative", | ||
| width: "100%", | ||
| height: "100%", | ||
| transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`, | ||
| transformOrigin: "center center", | ||
| cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default", | ||
| transition: isDragging ? "none" : "transform 0.1s ease-out", | ||
| }; | ||
| const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`; | ||
@@ -192,25 +227,56 @@ const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height); | ||
| return ( | ||
| <div className={className} style={containerStyle}> | ||
| {BACKGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))} | ||
| {/* Background Body - rendered as nested SVG for flat structure */} | ||
| <svg | ||
| x={translateX} | ||
| y="0" | ||
| width={width} | ||
| height={height} | ||
| viewBox={viewBox} | ||
| style={{ position: "absolute", top: "0", left: "0" }} | ||
| pointerEvents="none" | ||
| > | ||
| <title>Human body</title> | ||
| <Body | ||
| colorScale={colorPalette[DEFAULT_COLOR_NAME]} | ||
| style={{ | ||
| fill: "#fff", | ||
| }} | ||
| /> | ||
| </svg> | ||
| {FOREGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))} | ||
| <div | ||
| ref={containerRef} | ||
| className={className} | ||
| style={containerStyle} | ||
| onMouseEnter={() => setIsHovering(true)} | ||
| onMouseLeave={() => { | ||
| setIsHovering(false); | ||
| handleMouseUp(); | ||
| }} | ||
| onMouseDown={handleMouseDown} | ||
| onMouseMove={handleMouseMove} | ||
| onMouseUp={handleMouseUp} | ||
| role="application" | ||
| // biome-ignore lint/a11y/noNoninteractiveTabindex: Required for keyboard accessibility in zoom/pan application | ||
| tabIndex={0} | ||
| > | ||
| <div style={contentStyle}> | ||
| {BACKGROUND_ORGANS.map((organId) => | ||
| renderOrgan(organId, visibleOrganIds.includes(organId)), | ||
| )} | ||
| {/* Background Body - rendered as nested SVG for flat structure */} | ||
| <svg | ||
| x={translateX} | ||
| y="0" | ||
| width={width} | ||
| height={height} | ||
| viewBox={viewBox} | ||
| style={{ position: "absolute", top: "0", left: "0" }} | ||
| pointerEvents="none" | ||
| > | ||
| <title>Human body</title> | ||
| <Body | ||
| colorScale={colorPalette[DEFAULT_COLOR_NAME]} | ||
| style={{ | ||
| fill: "#fff", | ||
| }} | ||
| /> | ||
| </svg> | ||
| {FOREGROUND_ORGANS.map((organId) => | ||
| renderOrgan(organId, visibleOrganIds.includes(organId)), | ||
| )} | ||
| </div> | ||
| <ZoomControls | ||
| onZoomIn={zoomIn} | ||
| onZoomOut={zoomOut} | ||
| onReset={resetZoom} | ||
| showResetButton={!isDefaultZoom} | ||
| zoom={zoom} | ||
| minZoom={MIN_ZOOM} | ||
| maxZoom={maxZoom} | ||
| isVisible={isHovering} | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
+4
-0
@@ -7,5 +7,8 @@ // Main component | ||
| DEFAULT_COLOR_PALETTE, | ||
| HPO_LABEL_TO_ORGAN, | ||
| HPO_LABELS, | ||
| ORGAN_IDS, | ||
| ORGAN_NAMES_EN, | ||
| ORGAN_NAMES_KO, | ||
| ORGAN_TO_HPO_LABEL, | ||
| } from "./constants"; | ||
@@ -22,2 +25,3 @@ export { HpoVisualizer } from "./HpoVisualizer"; | ||
| ColorScale as ColorPalette, | ||
| HPOLabel, | ||
| HpoVisualizerProps, | ||
@@ -24,0 +28,0 @@ OrganConfig, |
+32
-0
@@ -32,2 +32,32 @@ import type { CSSProperties } from "react"; | ||
| /** | ||
| * HPO official category labels | ||
| * @see https://hpo.jax.org/ | ||
| */ | ||
| export type HPOLabel = | ||
| | "others" | ||
| | "Growth abnormality" | ||
| | "Abnormality of the genitourinary system" | ||
| | "Abnormality of the immune system" | ||
| | "Abnormality of the digestive system" | ||
| | "Abnormality of metabolism/homeostasis" | ||
| | "Abnormality of head or neck" | ||
| | "Abnormality of the musculoskeletal system" | ||
| | "Abnormality of the nervous system" | ||
| | "Abnormality of the respiratory system" | ||
| | "Abnormality of the eye" | ||
| | "Abnormality of the cardiovascular system" | ||
| | "Abnormality of the ear" | ||
| | "Abnormality of prenatal development or birth" | ||
| | "Abnormality of the integument" | ||
| | "Abnormality of the breast" | ||
| | "Abnormality of the endocrine system" | ||
| | "Abnormality of blood and blood-forming tissues" | ||
| | "Abnormality of limbs" | ||
| | "Abnormality of the voice" | ||
| | "Constitutional symptom" | ||
| | "Neoplasm" | ||
| | "Abnormal cellular phenotype" | ||
| | "Abnormality of the thoracic cavity"; | ||
| /** | ||
| * Color scheme for each organ. | ||
@@ -163,2 +193,4 @@ */ | ||
| style?: CSSProperties; | ||
| /** Maximum zoom level (default: 5 = 500%) */ | ||
| maxZoom?: number; | ||
| } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
845378
13.53%50
11.11%7472
23.2%