hpo-react-visualizer
Advanced tools
| import { render } from "@testing-library/react"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { HpoVisualizer } from "../HpoVisualizer"; | ||
| describe("HpoVisualizer sizing", () => { | ||
| it("should render root svg with percentage sizing by default", () => { | ||
| const { container } = render(<HpoVisualizer />); | ||
| const rootSvg = container.querySelector( | ||
| "svg[aria-label='Human organ visualizer']", | ||
| ) as SVGSVGElement; | ||
| expect(rootSvg).toBeDefined(); | ||
| expect(rootSvg.getAttribute("width")).toBe("100%"); | ||
| expect(rootSvg.getAttribute("height")).toBe("100%"); | ||
| }); | ||
| it("should apply width/height props to the container", () => { | ||
| const { container } = render(<HpoVisualizer width={300} height={400} />); | ||
| const root = container.firstElementChild as HTMLElement; | ||
| expect(root.style.width).toBe("300px"); | ||
| expect(root.style.height).toBe("400px"); | ||
| }); | ||
| }); |
| import { describe, expect, it } from "vitest"; | ||
| import { ORGAN_IDS } from "../constants"; | ||
| import { createOrganOutlineSet, createUniformOrganColorSchemes } from "../lib/organControlState"; | ||
| describe("organ control helpers", () => { | ||
| it("creates uniform color schemes for all organs", () => { | ||
| const schemes = createUniformOrganColorSchemes(ORGAN_IDS, "yellow"); | ||
| for (const organId of ORGAN_IDS) { | ||
| expect(schemes[organId]).toBe("yellow"); | ||
| } | ||
| }); | ||
| it("creates outline sets based on the enabled flag", () => { | ||
| const enabledSet = createOrganOutlineSet(ORGAN_IDS, true); | ||
| const disabledSet = createOrganOutlineSet(ORGAN_IDS, false); | ||
| expect(enabledSet.size).toBe(ORGAN_IDS.length); | ||
| for (const organId of ORGAN_IDS) { | ||
| expect(enabledSet.has(organId)).toBe(true); | ||
| } | ||
| expect(disabledSet.size).toBe(0); | ||
| }); | ||
| }); |
| import { describe, expect, it } from "vitest"; | ||
| import { TRANSITION_STYLE } from "../constants"; | ||
| describe("TRANSITION_STYLE", () => { | ||
| it("limits transitions to visual properties", () => { | ||
| expect(TRANSITION_STYLE).not.toContain("all"); | ||
| expect(TRANSITION_STYLE).toContain("fill"); | ||
| expect(TRANSITION_STYLE).toContain("opacity"); | ||
| }); | ||
| }); |
| import { act, renderHook } from "@testing-library/react"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import type { OrganId } from "../types"; | ||
| import { useOrganInteraction } from "../useOrganInteraction"; | ||
| describe("useOrganInteraction hook", () => { | ||
| it("keeps hover state when deselecting a hovered organ", () => { | ||
| const organId: OrganId = "head"; | ||
| const { result } = renderHook(() => useOrganInteraction()); | ||
| act(() => { | ||
| result.current.handlers.handleMouseEnter(organId); | ||
| }); | ||
| expect(result.current.state.hoveredOrgan).toBe(organId); | ||
| act(() => { | ||
| result.current.handlers.handleClick(organId); | ||
| }); | ||
| expect(result.current.state.selectedOrgan).toBe(organId); | ||
| expect(result.current.state.hoveredOrgan).toBe(organId); | ||
| act(() => { | ||
| result.current.handlers.handleClick(organId); | ||
| }); | ||
| expect(result.current.state.selectedOrgan).toBeNull(); | ||
| expect(result.current.state.hoveredOrgan).toBe(organId); | ||
| }); | ||
| }); |
| import type { ColorScheme, OrganId } from "../types"; | ||
| export const createUniformOrganColorSchemes = ( | ||
| organIds: readonly OrganId[], | ||
| scheme: ColorScheme, | ||
| ): Record<OrganId, ColorScheme> => { | ||
| return organIds.reduce( | ||
| (acc, organId) => { | ||
| acc[organId] = scheme; | ||
| return acc; | ||
| }, | ||
| {} as Record<OrganId, ColorScheme>, | ||
| ); | ||
| }; | ||
| export const createOrganOutlineSet = ( | ||
| organIds: readonly OrganId[], | ||
| enabled: boolean, | ||
| ): Set<OrganId> => { | ||
| return enabled ? new Set(organIds) : new Set<OrganId>(); | ||
| }; |
+13
-6
@@ -17,2 +17,3 @@ import { CSSProperties, ComponentType } from 'react'; | ||
| type ColorName = "blue" | "yellow" | "gray"; | ||
| type ColorScheme = ColorName; | ||
| /** | ||
@@ -140,2 +141,4 @@ * Color step | ||
| maxZoom?: number; | ||
| /** Enable zoom with mouse wheel (default: true) */ | ||
| wheelZoom?: boolean; | ||
| } | ||
@@ -189,4 +192,7 @@ | ||
| */ | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, maxZoom, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, maxZoom, wheelZoom, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
| declare const createUniformOrganColorSchemes: (organIds: readonly OrganId[], scheme: ColorScheme) => Record<OrganId, ColorScheme>; | ||
| declare const createOrganOutlineSet: (organIds: readonly OrganId[], enabled: boolean) => Set<OrganId>; | ||
| interface OrganSvgWrapperProps { | ||
@@ -209,9 +215,9 @@ /** Organ identifier */ | ||
| onClick: () => void; | ||
| /** X position in parent coordinate system */ | ||
| /** X position in parent viewBox coordinate system */ | ||
| x: number; | ||
| /** Y position in parent coordinate system */ | ||
| /** Y position in parent viewBox coordinate system */ | ||
| y: number; | ||
| /** Width of the organ SVG viewport */ | ||
| /** Width of the organ SVG viewport in viewBox units */ | ||
| width: number; | ||
| /** Height of the organ SVG viewport */ | ||
| /** Height of the organ SVG viewport in viewBox units */ | ||
| height: number; | ||
@@ -265,2 +271,3 @@ /** ViewBox for the organ SVG (local coordinate system) */ | ||
| * - Click on different organ changes selection | ||
| * - Deselect keeps hover state when still over the organ | ||
| * - Hover effects work independently even when an organ is selected | ||
@@ -270,2 +277,2 @@ */ | ||
| 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 }; | ||
| 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, createOrganOutlineSet, createUniformOrganColorSchemes, useOrganInteraction }; |
+13
-6
@@ -17,2 +17,3 @@ import { CSSProperties, ComponentType } from 'react'; | ||
| type ColorName = "blue" | "yellow" | "gray"; | ||
| type ColorScheme = ColorName; | ||
| /** | ||
@@ -140,2 +141,4 @@ * Color step | ||
| maxZoom?: number; | ||
| /** Enable zoom with mouse wheel (default: true) */ | ||
| wheelZoom?: boolean; | ||
| } | ||
@@ -189,4 +192,7 @@ | ||
| */ | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, maxZoom, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
| declare function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width, height, className, style, maxZoom, wheelZoom, }: HpoVisualizerProps): react_jsx_runtime.JSX.Element; | ||
| declare const createUniformOrganColorSchemes: (organIds: readonly OrganId[], scheme: ColorScheme) => Record<OrganId, ColorScheme>; | ||
| declare const createOrganOutlineSet: (organIds: readonly OrganId[], enabled: boolean) => Set<OrganId>; | ||
| interface OrganSvgWrapperProps { | ||
@@ -209,9 +215,9 @@ /** Organ identifier */ | ||
| onClick: () => void; | ||
| /** X position in parent coordinate system */ | ||
| /** X position in parent viewBox coordinate system */ | ||
| x: number; | ||
| /** Y position in parent coordinate system */ | ||
| /** Y position in parent viewBox coordinate system */ | ||
| y: number; | ||
| /** Width of the organ SVG viewport */ | ||
| /** Width of the organ SVG viewport in viewBox units */ | ||
| width: number; | ||
| /** Height of the organ SVG viewport */ | ||
| /** Height of the organ SVG viewport in viewBox units */ | ||
| height: number; | ||
@@ -265,2 +271,3 @@ /** ViewBox for the organ SVG (local coordinate system) */ | ||
| * - Click on different organ changes selection | ||
| * - Deselect keeps hover state when still over the organ | ||
| * - Hover effects work independently even when an organ is selected | ||
@@ -270,2 +277,2 @@ */ | ||
| 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 }; | ||
| 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, createOrganOutlineSet, createUniformOrganColorSchemes, useOrganInteraction }; |
+15
-15
| { | ||
| "name": "hpo-react-visualizer", | ||
| "version": "0.0.2", | ||
| "version": "0.0.3", | ||
| "description": "Interactive Human Phenotype Ontology (HPO) organ visualization library", | ||
@@ -33,13 +33,2 @@ "private": false, | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "dev": "tsup --watch", | ||
| "lint": "biome check .", | ||
| "lint:fix": "biome check --write .", | ||
| "generate:component": "turbo gen react-component", | ||
| "check-types": "tsc --noEmit", | ||
| "clean": "rm -rf dist", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest" | ||
| }, | ||
| "peerDependencies": { | ||
@@ -50,3 +39,2 @@ "react": "^18.0.0 || ^19.0.0", | ||
| "devDependencies": { | ||
| "@repo/typescript-config": "workspace:*", | ||
| "@testing-library/jest-dom": "^6.9.1", | ||
@@ -60,4 +48,16 @@ "@testing-library/react": "^16.3.0", | ||
| "typescript": "5.9.2", | ||
| "vitest": "^4.0.15" | ||
| "vitest": "^4.0.15", | ||
| "@repo/typescript-config": "0.0.0" | ||
| }, | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "dev": "tsup --watch", | ||
| "lint": "biome check .", | ||
| "lint:fix": "biome check --write .", | ||
| "generate:component": "turbo gen react-component", | ||
| "check-types": "tsc --noEmit", | ||
| "clean": "rm -rf dist", | ||
| "test": "vitest run", | ||
| "test:watch": "vitest" | ||
| } | ||
| } | ||
| } |
@@ -9,11 +9,8 @@ import { render } from "@testing-library/react"; | ||
| * Helper function to get rendered organ IDs in DOM order. | ||
| * OrganSvg elements are now <svg> elements with aria-label attribute. | ||
| * OrganSvg elements are now <g> elements with aria-label attribute. | ||
| * Only includes visible organs (opacity !== 0). | ||
| */ | ||
| const getRenderedOrganOrder = (container: HTMLElement): string[] => { | ||
| // Find all OrganSvg elements (they are nested svg elements with aria-label) | ||
| // Exclude the root svg which has aria-label="Human organ visualizer" | ||
| const organElements = container.querySelectorAll( | ||
| "svg[aria-label]:not([aria-label='Human organ visualizer'])", | ||
| ); | ||
| // Find all OrganSvg elements (they are <g> elements with aria-label) | ||
| const organElements = container.querySelectorAll("g[aria-label]"); | ||
| return Array.from(organElements) | ||
@@ -29,12 +26,2 @@ .filter((el) => { | ||
| /** | ||
| * Helper function to get z-index of an organ element | ||
| */ | ||
| const getOrganZIndex = (container: HTMLElement, organId: string): number => { | ||
| const organElement = container.querySelector(`svg[aria-label="${organId}"]`) as HTMLElement; | ||
| if (!organElement) return -1; | ||
| const style = organElement.style.zIndex; | ||
| return style ? parseInt(style, 10) : 0; | ||
| }; | ||
| it("should render organs in ORGAN_POSITIONS key order regardless of organs array order", () => { | ||
@@ -69,3 +56,3 @@ // Provide organs in reverse alphabetical order | ||
| it("should give selectedOrgan higher z-index to appear on top", () => { | ||
| it("should render selectedOrgan after other organs to appear on top", () => { | ||
| const organs: { id: OrganId }[] = [ | ||
@@ -82,12 +69,8 @@ { id: "head" }, | ||
| // 'head' should have higher z-index since it's selected | ||
| expect(getOrganZIndex(container, "head")).toBe(1); | ||
| // Other organs should have z-index 0 | ||
| expect(getOrganZIndex(container, "lung")).toBe(0); | ||
| expect(getOrganZIndex(container, "heart")).toBe(0); | ||
| expect(getOrganZIndex(container, "eye")).toBe(0); | ||
| expect(getOrganZIndex(container, "ear")).toBe(0); | ||
| const renderedOrder = getRenderedOrganOrder(container); | ||
| // 'head' should be rendered last since it's selected | ||
| expect(renderedOrder.at(-1)).toBe("head"); | ||
| }); | ||
| it("should give selectedOrgan higher z-index even when it would normally be in the middle", () => { | ||
| it("should render selectedOrgan last even when it would normally be in the middle", () => { | ||
| const organs: { id: OrganId }[] = [ | ||
@@ -104,12 +87,8 @@ { id: "head" }, | ||
| // 'heart' should have higher z-index | ||
| expect(getOrganZIndex(container, "heart")).toBe(1); | ||
| // Other organs should have z-index 0 | ||
| expect(getOrganZIndex(container, "lung")).toBe(0); | ||
| expect(getOrganZIndex(container, "digestive")).toBe(0); | ||
| expect(getOrganZIndex(container, "kidney")).toBe(0); | ||
| expect(getOrganZIndex(container, "head")).toBe(0); | ||
| const renderedOrder = getRenderedOrganOrder(container); | ||
| expect(renderedOrder.at(-1)).toBe("heart"); | ||
| expect(renderedOrder.slice(0, -1)).toEqual(["lung", "digestive", "kidney", "head"]); | ||
| }); | ||
| it("should render all organs in ORGAN_RENDER_ORDER (no reordering for selection)", () => { | ||
| it("should render selected organ last while preserving order for others", () => { | ||
| const organs: { id: OrganId }[] = [{ id: "head" }, { id: "eye" }, { id: "ear" }]; | ||
@@ -121,6 +100,3 @@ | ||
| // All organs should be rendered in ORGAN_RENDER_ORDER, not reordered | ||
| expect(renderedOrder).toEqual(["head", "eye", "ear"]); | ||
| // 'eye' has higher z-index because it's selected | ||
| expect(getOrganZIndex(container, "eye")).toBe(1); | ||
| expect(renderedOrder).toEqual(["head", "ear", "eye"]); | ||
| }); | ||
@@ -152,2 +128,13 @@ | ||
| }); | ||
| it("should return deselected organ to normal order immediately", () => { | ||
| const organs: { id: OrganId }[] = [{ id: "head" }, { id: "eye" }, { id: "ear" }]; | ||
| const { container, rerender } = render(<HpoVisualizer organs={organs} selectedOrgan="eye" />); | ||
| expect(getRenderedOrganOrder(container)).toEqual(["head", "ear", "eye"]); | ||
| rerender(<HpoVisualizer organs={organs} selectedOrgan={null} />); | ||
| expect(getRenderedOrganOrder(container)).toEqual(["head", "eye", "ear"]); | ||
| }); | ||
| }); |
@@ -110,2 +110,36 @@ import { act, renderHook } from "@testing-library/react"; | ||
| }); | ||
| describe("wheelZoom option", () => { | ||
| it("should accept wheelZoom option with default value true", () => { | ||
| const { result } = renderHook(() => useZoom()); | ||
| // Hook should work normally with default wheelZoom=true | ||
| expect(result.current.zoom).toBe(1); | ||
| expect(result.current.containerRef).toBeDefined(); | ||
| }); | ||
| it("should accept wheelZoom option set to false", () => { | ||
| const { result } = renderHook(() => useZoom({ wheelZoom: false })); | ||
| // Hook should work normally with wheelZoom=false | ||
| expect(result.current.zoom).toBe(1); | ||
| expect(result.current.containerRef).toBeDefined(); | ||
| }); | ||
| it("should still allow button zoom when wheelZoom is disabled", () => { | ||
| const { result } = renderHook(() => useZoom({ wheelZoom: false })); | ||
| act(() => { | ||
| result.current.zoomIn(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1.5); | ||
| act(() => { | ||
| result.current.zoomOut(); | ||
| }); | ||
| expect(result.current.zoom).toBe(1); | ||
| }); | ||
| }); | ||
| }); |
+14
-1
@@ -239,4 +239,17 @@ import type { ColorName, HPOLabel, OrganId, StrictColorPalette } from "./types"; | ||
| */ | ||
| export const TRANSITION_STYLE = `all ${ANIMATION_DURATION_MS}ms ease-out`; | ||
| const TRANSITION_PROPERTIES = [ | ||
| "fill", | ||
| "stroke", | ||
| "stroke-width", | ||
| "opacity", | ||
| "filter", | ||
| "stop-color", | ||
| "stop-opacity", | ||
| "visibility", | ||
| ]; | ||
| export const TRANSITION_STYLE = TRANSITION_PROPERTIES.map( | ||
| (property) => `${property} ${ANIMATION_DURATION_MS}ms ease-out`, | ||
| ).join(", "); | ||
| /** | ||
@@ -243,0 +256,0 @@ * Body SVG viewBox dimensions |
+103
-37
@@ -71,2 +71,4 @@ import { type CSSProperties, useId, useMemo, useState } from "react"; | ||
| type RenderEntry = { type: "organ"; organId: OrganId } | { type: "body" }; | ||
| const MIN_ZOOM = 1; | ||
@@ -93,7 +95,8 @@ | ||
| colorPalette: inputColorPalette, | ||
| width = BODY_VIEWBOX.width, | ||
| height = BODY_VIEWBOX.height, | ||
| width = "100%", | ||
| height = "100%", | ||
| className, | ||
| style, | ||
| maxZoom = 5, | ||
| wheelZoom = true, | ||
| }: HpoVisualizerProps) { | ||
@@ -116,3 +119,3 @@ const visualizerID = useId(); | ||
| containerRef, | ||
| } = useZoom({ minZoom: MIN_ZOOM, maxZoom }); | ||
| } = useZoom({ minZoom: MIN_ZOOM, maxZoom, wheelZoom, viewBox: BODY_VIEWBOX }); | ||
@@ -152,3 +155,3 @@ // Create strict color palette with all required colors and alpha values | ||
| // Use the interaction hook | ||
| const { handlers, isHovered, isSelected } = useOrganInteraction({ | ||
| const { handlers, isHovered, isSelected, state } = useOrganInteraction({ | ||
| hoveredOrgan: controlledHovered, | ||
@@ -160,2 +163,43 @@ selectedOrgan: controlledSelected, | ||
| const selectedOrganId = state.selectedOrgan; | ||
| const renderEntries = useMemo(() => { | ||
| const base: RenderEntry[] = [ | ||
| ...BACKGROUND_ORGANS.map((organId): RenderEntry => ({ type: "organ", organId })), | ||
| { type: "body" }, | ||
| ...FOREGROUND_ORGANS.map((organId): RenderEntry => ({ type: "organ", organId })), | ||
| ]; | ||
| if (!selectedOrganId) { | ||
| return base; | ||
| } | ||
| const selectedIndex = base.findIndex( | ||
| (entry) => entry.type === "organ" && entry.organId === selectedOrganId, | ||
| ); | ||
| if (selectedIndex === -1) { | ||
| return base; | ||
| } | ||
| const selectedEntry = base[selectedIndex]; | ||
| if (!selectedEntry || selectedEntry.type !== "organ") { | ||
| return base; | ||
| } | ||
| return [...base.slice(0, selectedIndex), ...base.slice(selectedIndex + 1), selectedEntry]; | ||
| }, [selectedOrganId]); | ||
| const { | ||
| padding, | ||
| paddingTop, | ||
| paddingRight, | ||
| paddingBottom, | ||
| paddingLeft, | ||
| paddingInline, | ||
| paddingInlineStart, | ||
| paddingInlineEnd, | ||
| paddingBlock, | ||
| paddingBlockStart, | ||
| paddingBlockEnd, | ||
| ...containerStyleOverrides | ||
| } = style ?? {}; | ||
| const containerStyle: CSSProperties = { | ||
@@ -169,5 +213,4 @@ display: "flex", | ||
| height: height, | ||
| ...style, | ||
| ...containerStyleOverrides, | ||
| // Apply overflow after style spread to ensure it takes precedence | ||
| // Use 'clip' instead of 'hidden' to respect padding | ||
| overflow: "clip", | ||
@@ -177,14 +220,33 @@ }; | ||
| const contentStyle: CSSProperties = { | ||
| position: "relative", | ||
| display: "flex", | ||
| justifyContent: "center", | ||
| alignItems: "flex-end", | ||
| width: "100%", | ||
| height: "100%", | ||
| transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`, | ||
| transformOrigin: "center center", | ||
| boxSizing: "border-box", | ||
| padding, | ||
| paddingTop, | ||
| paddingRight, | ||
| paddingBottom, | ||
| paddingLeft, | ||
| paddingInline, | ||
| paddingInlineStart, | ||
| paddingInlineEnd, | ||
| paddingBlock, | ||
| paddingBlockStart, | ||
| paddingBlockEnd, | ||
| }; | ||
| const svgStyle: CSSProperties = { | ||
| width: "100%", | ||
| height: "100%", | ||
| display: "block", | ||
| cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default", | ||
| transition: isDragging ? "none" : "transform 0.1s ease-out", | ||
| overflow: "visible", | ||
| }; | ||
| const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`; | ||
| const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height); | ||
| const translateX = (Number(width) - BODY_VIEWBOX.width * scale) / 2; | ||
| const centerX = BODY_VIEWBOX.width / 2; | ||
| const centerY = BODY_VIEWBOX.height / 2; | ||
| const zoomTransform = `translate(${centerX} ${centerY}) scale(${zoom}) translate(${-centerX} ${-centerY})`; | ||
@@ -201,6 +263,6 @@ // Helper function to render an organ | ||
| const x = translateX + position.x * scale; | ||
| const y = position.y * scale; | ||
| const width = position.width * scale; | ||
| const height = position.height * scale; | ||
| const x = position.x; | ||
| const y = position.y; | ||
| const width = position.width; | ||
| const height = position.height; | ||
@@ -231,3 +293,2 @@ return ( | ||
| <div | ||
| ref={containerRef} | ||
| className={className} | ||
@@ -248,26 +309,31 @@ style={containerStyle} | ||
| <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} | ||
| ref={containerRef} | ||
| width="100%" | ||
| height="100%" | ||
| viewBox={viewBox} | ||
| style={{ position: "absolute", top: "0", left: "0" }} | ||
| pointerEvents="none" | ||
| preserveAspectRatio="xMidYMid meet" | ||
| style={svgStyle} | ||
| aria-label="Human organ visualizer" | ||
| > | ||
| <title>Human body</title> | ||
| <Body | ||
| colorScale={colorPalette[DEFAULT_COLOR_NAME]} | ||
| style={{ | ||
| fill: "#fff", | ||
| }} | ||
| /> | ||
| <title>Human organ visualizer</title> | ||
| <g transform={`translate(${pan.x} ${pan.y})`}> | ||
| <g transform={zoomTransform}> | ||
| {renderEntries.map((entry) => { | ||
| if (entry.type === "body") { | ||
| return ( | ||
| <Body | ||
| key={`${visualizerID}-body`} | ||
| colorScale={colorPalette[DEFAULT_COLOR_NAME]} | ||
| style={{ | ||
| fill: "#fff", | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
| return renderOrgan(entry.organId, visibleOrganIds.includes(entry.organId)); | ||
| })} | ||
| </g> | ||
| </g> | ||
| </svg> | ||
| {FOREGROUND_ORGANS.map((organId) => | ||
| renderOrgan(organId, visibleOrganIds.includes(organId)), | ||
| )} | ||
| </div> | ||
@@ -274,0 +340,0 @@ <ZoomControls |
+1
-0
@@ -15,2 +15,3 @@ // Main component | ||
| export { HpoVisualizer } from "./HpoVisualizer"; | ||
| export { createOrganOutlineSet, createUniformOrganColorSchemes } from "./lib"; | ||
| export type { OrganSvgWrapperProps } from "./OrganSvg"; | ||
@@ -17,0 +18,0 @@ // OrganSvg wrapper for custom compositions |
+1
-0
| export { createStrictColorPalette } from "./createStrictColorPalette"; | ||
| export { createOrganOutlineSet, createUniformOrganColorSchemes } from "./organControlState"; |
+13
-17
@@ -28,9 +28,9 @@ import { type CSSProperties, useMemo } from "react"; | ||
| onClick: () => void; | ||
| /** X position in parent coordinate system */ | ||
| /** X position in parent viewBox coordinate system */ | ||
| x: number; | ||
| /** Y position in parent coordinate system */ | ||
| /** Y position in parent viewBox coordinate system */ | ||
| y: number; | ||
| /** Width of the organ SVG viewport */ | ||
| /** Width of the organ SVG viewport in viewBox units */ | ||
| width: number; | ||
| /** Height of the organ SVG viewport */ | ||
| /** Height of the organ SVG viewport in viewBox units */ | ||
| height: number; | ||
@@ -97,8 +97,8 @@ /** ViewBox for the organ SVG (local coordinate system) */ | ||
| const svgStyle: CSSProperties = { | ||
| position: "absolute", | ||
| left: x, | ||
| top: y, | ||
| const [minX = 0, minY = 0, viewBoxWidth, viewBoxHeight] = viewBox.split(" ").map(Number); | ||
| const scaleX = viewBoxWidth ? width / viewBoxWidth : 1; | ||
| const scaleY = viewBoxHeight ? height / viewBoxHeight : 1; | ||
| const transform = `translate(${x} ${y}) scale(${scaleX} ${scaleY}) translate(${-minX} ${-minY})`; | ||
| const groupStyle: CSSProperties = { | ||
| transition: `${TRANSITION_STYLE}, visibility 0s`, | ||
| zIndex: isSelected ? 1 : 0, | ||
| }; | ||
@@ -108,11 +108,7 @@ const filter = isActive ? "blur(1px)" : undefined; | ||
| return ( | ||
| <svg | ||
| width={width} | ||
| height={height} | ||
| viewBox={viewBox} | ||
| style={svgStyle} | ||
| <g | ||
| transform={transform} | ||
| style={groupStyle} | ||
| filter={filter} | ||
| aria-label={organId} | ||
| overflow="visible" | ||
| pointerEvents="none" | ||
| opacity={isVisible ? 1 : 0} | ||
@@ -142,4 +138,4 @@ visibility={isVisible ? "visible" : "hidden"} | ||
| </g> | ||
| </svg> | ||
| </g> | ||
| ); | ||
| } |
@@ -18,2 +18,3 @@ import { TRANSITION_STYLE } from "../constants"; | ||
| /> | ||
| <path d={OUTLINE_NEOPLASM_PATH} fill="transparent" style={{ transition: TRANSITION_STYLE }} /> | ||
| </g> | ||
@@ -25,1 +26,3 @@ ); | ||
| "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"; | ||
| const OUTLINE_NEOPLASM_PATH = | ||
| "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"; |
+3
-0
@@ -65,2 +65,3 @@ import type { CSSProperties } from "react"; | ||
| export type ColorName = "blue" | "yellow" | "gray"; | ||
| export type ColorScheme = ColorName; | ||
| /** | ||
@@ -195,2 +196,4 @@ * Color step | ||
| maxZoom?: number; | ||
| /** Enable zoom with mouse wheel (default: true) */ | ||
| wheelZoom?: boolean; | ||
| } |
@@ -36,2 +36,3 @@ import { useCallback, useMemo, useState } from "react"; | ||
| * - Click on different organ changes selection | ||
| * - Deselect keeps hover state when still over the organ | ||
| * - Hover effects work independently even when an organ is selected | ||
@@ -86,12 +87,4 @@ */ | ||
| onSelect?.(newSelected); | ||
| // When deselecting, also clear hover state | ||
| if (newSelected === null) { | ||
| if (!isHoverControlled) { | ||
| setInternalHovered(null); | ||
| } | ||
| onHover?.(null); | ||
| } | ||
| }, | ||
| [selectedOrgan, isSelectControlled, isHoverControlled, onSelect, onHover], | ||
| [selectedOrgan, isSelectControlled, onSelect], | ||
| ); | ||
@@ -98,0 +91,0 @@ |
+224
-73
@@ -10,2 +10,6 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; | ||
| zoomStep?: number; | ||
| /** Enable zoom with mouse wheel (default: true) */ | ||
| wheelZoom?: boolean; | ||
| /** ViewBox dimensions for coordinate mapping */ | ||
| viewBox?: { width: number; height: number }; | ||
| } | ||
@@ -35,5 +39,9 @@ | ||
| /** Ref to attach to the container element for wheel event handling */ | ||
| containerRef: RefObject<HTMLDivElement | null>; | ||
| containerRef: RefObject<SVGSVGElement | null>; | ||
| } | ||
| const ZOOM_ANIMATION_MS = 180; | ||
| const easeOutCubic = (t: number) => 1 - (1 - t) ** 3; | ||
| /** | ||
@@ -43,3 +51,3 @@ * Custom hook for zoom and pan functionality | ||
| export function useZoom(options: UseZoomOptions = {}): UseZoomReturn { | ||
| const { minZoom = 1, maxZoom = 5, zoomStep = 0.5 } = options; | ||
| const { minZoom = 1, maxZoom = 5, zoomStep = 0.5, wheelZoom = true, viewBox } = options; | ||
@@ -51,3 +59,6 @@ const [zoom, setZoom] = useState(1); | ||
| const panStartRef = useRef({ x: 0, y: 0 }); | ||
| const containerRef = useRef<HTMLDivElement | null>(null); | ||
| const containerRef = useRef<SVGSVGElement | null>(null); | ||
| const zoomRef = useRef(1); | ||
| const panRef = useRef({ x: 0, y: 0 }); | ||
| const animationRef = useRef<number | null>(null); | ||
@@ -59,15 +70,168 @@ const clampZoom = useCallback( | ||
| const stopAnimation = useCallback(() => { | ||
| if (animationRef.current !== null) { | ||
| cancelAnimationFrame(animationRef.current); | ||
| animationRef.current = null; | ||
| } | ||
| }, []); | ||
| const animateZoom = useCallback( | ||
| ( | ||
| targetZoom: number, | ||
| options?: { targetPan?: { x: number; y: number }; durationMs?: number }, | ||
| ) => { | ||
| const { targetPan, durationMs = ZOOM_ANIMATION_MS } = options ?? {}; | ||
| stopAnimation(); | ||
| const startZoom = zoomRef.current; | ||
| const clampedTargetZoom = clampZoom(targetZoom); | ||
| const startPan = panRef.current; | ||
| const hasPanTarget = targetPan !== undefined; | ||
| const finalPan = targetPan ?? startPan; | ||
| if ( | ||
| durationMs <= 0 || | ||
| (clampedTargetZoom === startZoom && | ||
| (!hasPanTarget || (finalPan.x === startPan.x && finalPan.y === startPan.y))) | ||
| ) { | ||
| setZoom(clampedTargetZoom); | ||
| zoomRef.current = clampedTargetZoom; | ||
| if (hasPanTarget) { | ||
| setPan(finalPan); | ||
| panRef.current = finalPan; | ||
| } | ||
| return; | ||
| } | ||
| const startTime = performance.now(); | ||
| const step = (now: number) => { | ||
| const elapsed = now - startTime; | ||
| const t = Math.min(elapsed / durationMs, 1); | ||
| const eased = easeOutCubic(t); | ||
| const nextZoom = startZoom + (clampedTargetZoom - startZoom) * eased; | ||
| zoomRef.current = nextZoom; | ||
| setZoom(nextZoom); | ||
| if (hasPanTarget) { | ||
| const nextPan = { | ||
| x: startPan.x + (finalPan.x - startPan.x) * eased, | ||
| y: startPan.y + (finalPan.y - startPan.y) * eased, | ||
| }; | ||
| panRef.current = nextPan; | ||
| setPan(nextPan); | ||
| } | ||
| if (t < 1) { | ||
| animationRef.current = requestAnimationFrame(step); | ||
| } else { | ||
| animationRef.current = null; | ||
| setZoom(clampedTargetZoom); | ||
| zoomRef.current = clampedTargetZoom; | ||
| if (hasPanTarget) { | ||
| setPan(finalPan); | ||
| panRef.current = finalPan; | ||
| } | ||
| } | ||
| }; | ||
| animationRef.current = requestAnimationFrame(step); | ||
| }, | ||
| [clampZoom, stopAnimation], | ||
| ); | ||
| const zoomIn = useCallback(() => { | ||
| setZoom((prev) => clampZoom(prev + zoomStep)); | ||
| }, [clampZoom, zoomStep]); | ||
| const nextZoom = clampZoom(zoomRef.current + zoomStep); | ||
| animateZoom(nextZoom); | ||
| }, [animateZoom, clampZoom, zoomStep]); | ||
| const zoomOut = useCallback(() => { | ||
| setZoom((prev) => clampZoom(prev - zoomStep)); | ||
| }, [clampZoom, zoomStep]); | ||
| const nextZoom = clampZoom(zoomRef.current - zoomStep); | ||
| animateZoom(nextZoom); | ||
| }, [animateZoom, clampZoom, zoomStep]); | ||
| const resetZoom = useCallback(() => { | ||
| setZoom(1); | ||
| setPan({ x: 0, y: 0 }); | ||
| }, []); | ||
| animateZoom(1, { targetPan: { x: 0, y: 0 } }); | ||
| }, [animateZoom]); | ||
| const getViewBoxSize = useCallback( | ||
| (container: SVGSVGElement) => { | ||
| if (viewBox?.width && viewBox?.height) { | ||
| return { width: viewBox.width, height: viewBox.height }; | ||
| } | ||
| const rect = container.getBoundingClientRect(); | ||
| return { width: rect.width || 1, height: rect.height || 1 }; | ||
| }, | ||
| [viewBox?.width, viewBox?.height], | ||
| ); | ||
| const getViewBoxMetrics = useCallback( | ||
| (container: SVGSVGElement) => { | ||
| const rect = container.getBoundingClientRect(); | ||
| const { width: viewBoxWidth, height: viewBoxHeight } = getViewBoxSize(container); | ||
| const safeWidth = viewBoxWidth || 1; | ||
| const safeHeight = viewBoxHeight || 1; | ||
| const scale = | ||
| rect.width > 0 && rect.height > 0 | ||
| ? Math.min(rect.width / safeWidth, rect.height / safeHeight) | ||
| : 0; | ||
| const contentWidth = safeWidth * scale; | ||
| const contentHeight = safeHeight * scale; | ||
| const offsetX = (rect.width - contentWidth) / 2; | ||
| const offsetY = (rect.height - contentHeight) / 2; | ||
| return { rect, viewBoxWidth: safeWidth, viewBoxHeight: safeHeight, scale, offsetX, offsetY }; | ||
| }, | ||
| [getViewBoxSize], | ||
| ); | ||
| const getPointerPosition = useCallback( | ||
| (container: SVGSVGElement, clientX: number, clientY: number) => { | ||
| const ctm = container.getScreenCTM(); | ||
| if (ctm) { | ||
| if (typeof DOMPoint !== "undefined") { | ||
| const point = new DOMPoint(clientX, clientY); | ||
| const { x, y } = point.matrixTransform(ctm.inverse()); | ||
| return { x, y }; | ||
| } | ||
| if ("createSVGPoint" in container) { | ||
| const point = container.createSVGPoint(); | ||
| point.x = clientX; | ||
| point.y = clientY; | ||
| const { x, y } = point.matrixTransform(ctm.inverse()); | ||
| return { x, y }; | ||
| } | ||
| } | ||
| const { rect, scale, offsetX, offsetY } = getViewBoxMetrics(container); | ||
| if (scale <= 0) return null; | ||
| return { | ||
| x: (clientX - rect.left - offsetX) / scale, | ||
| y: (clientY - rect.top - offsetY) / scale, | ||
| }; | ||
| }, | ||
| [getViewBoxMetrics], | ||
| ); | ||
| const clampPan = useCallback( | ||
| ( | ||
| newPan: { x: number; y: number }, | ||
| currentZoom: number, | ||
| viewBoxWidth: number, | ||
| viewBoxHeight: number, | ||
| ) => { | ||
| if (currentZoom <= 1) { | ||
| return { x: 0, y: 0 }; | ||
| } | ||
| const maxPanX = (viewBoxWidth * (currentZoom - 1)) / 2; | ||
| const maxPanY = (viewBoxHeight * (currentZoom - 1)) / 2; | ||
| return { | ||
| x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)), | ||
| y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y)), | ||
| }; | ||
| }, | ||
| [], | ||
| ); | ||
| // Use native event listener with passive: false to allow preventDefault | ||
@@ -77,4 +241,16 @@ // Use smaller step for wheel (0.1) than buttons (zoomStep) for smoother zooming | ||
| useEffect(() => { | ||
| zoomRef.current = zoom; | ||
| }, [zoom]); | ||
| useEffect(() => { | ||
| panRef.current = pan; | ||
| }, [pan]); | ||
| useEffect(() => { | ||
| return () => stopAnimation(); | ||
| }, [stopAnimation]); | ||
| useEffect(() => { | ||
| const container = containerRef.current; | ||
| if (!container) return; | ||
| if (!container || !wheelZoom) return; | ||
@@ -84,39 +260,28 @@ const wheelZoomStep = 0.1; | ||
| e.preventDefault(); | ||
| stopAnimation(); | ||
| 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 { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container); | ||
| const pointer = getPointerPosition(container, e.clientX, e.clientY); | ||
| if (!pointer) return; | ||
| const { x: mouseX, y: mouseY } = pointer; | ||
| const centerX = viewBoxWidth / 2; | ||
| const centerY = viewBoxHeight / 2; | ||
| const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep; | ||
| const currentZoom = zoomRef.current; | ||
| const nextZoom = clampZoom(currentZoom + delta); | ||
| if (nextZoom === currentZoom) return; | ||
| setZoom((prevZoom) => { | ||
| const newZoom = clampZoom(prevZoom + delta); | ||
| if (newZoom === prevZoom) return prevZoom; | ||
| const zoomRatio = nextZoom / currentZoom; | ||
| const nextPan = { | ||
| x: (mouseX - centerX) * (1 - zoomRatio) + panRef.current.x * zoomRatio, | ||
| y: (mouseY - centerY) * (1 - zoomRatio) + panRef.current.y * zoomRatio, | ||
| }; | ||
| const clampedPan = clampPan(nextPan, nextZoom, viewBoxWidth, viewBoxHeight); | ||
| 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; | ||
| }); | ||
| zoomRef.current = nextZoom; | ||
| panRef.current = clampedPan; | ||
| setZoom(nextZoom); | ||
| setPan(clampedPan); | ||
| }; | ||
@@ -129,30 +294,11 @@ | ||
| }; | ||
| }, [clampZoom]); | ||
| }, [clampZoom, wheelZoom, getViewBoxMetrics, getPointerPosition, clampPan, stopAnimation]); | ||
| // 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 container = containerRef.current; | ||
| if (!container) return; | ||
| const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container); | ||
| setPan((currentPan) => clampPan(currentPan, zoom, viewBoxWidth, viewBoxHeight)); | ||
| }, [zoom, clampPan, getViewBoxMetrics]); | ||
@@ -163,2 +309,3 @@ const handleMouseDown = useCallback( | ||
| if (e.button !== 0 || zoom <= 1) return; | ||
| stopAnimation(); | ||
| setIsDragging(true); | ||
@@ -169,3 +316,3 @@ dragStartRef.current = { x: e.clientX, y: e.clientY }; | ||
| }, | ||
| [pan, zoom], | ||
| [pan, zoom, stopAnimation], | ||
| ); | ||
@@ -176,11 +323,15 @@ | ||
| if (!isDragging || zoom <= 1) return; | ||
| const container = containerRef.current; | ||
| if (!container) return; | ||
| const { scale, viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container); | ||
| if (scale <= 0) 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, | ||
| x: panStartRef.current.x + dx / scale, | ||
| y: panStartRef.current.y + dy / scale, | ||
| }; | ||
| setPan(clampPan(newPan, zoom)); | ||
| setPan(clampPan(newPan, zoom, viewBoxWidth, viewBoxHeight)); | ||
| }, | ||
| [isDragging, zoom, clampPan], | ||
| [isDragging, zoom, clampPan, getViewBoxMetrics], | ||
| ); | ||
@@ -187,0 +338,0 @@ |
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
906496
7.23%55
10%8193
9.65%