import { type CSSProperties, useId, useMemo, useState } from "react"; import { BODY_VIEWBOX, DEFAULT_COLOR_NAME, ORGAN_IDS } from "./constants"; import { createStrictColorPalette } from "./lib"; import { OrganSvg } from "./OrganSvg"; import { Body } from "./svg/Body"; import type { HpoVisualizerProps, OrganConfig, OrganId, StrictColorPalette } from "./types"; import { useOrganInteraction } from "./useOrganInteraction"; import { useZoom } from "./useZoom"; import { ZoomControls } from "./ZoomControls"; /** * Default positions and viewBox dimensions for each organ in the visualizer * Positions are relative to Body SVG coordinate system (122x325 viewBox) * viewBox: original coordinate system of the organ SVG */ const ORGAN_POSITIONS: Record< OrganId, { x: number; y: number; width: number; height: number; viewBox: string } > = { growth: { x: 104, y: 1, width: 12, height: 349, viewBox: "0 0 12 349" }, constitutional: { x: 0, y: 0, width: 122, height: 358, viewBox: "0 0 122 358" }, thoracicCavity: { x: 31, y: 79, width: 60, height: 50, viewBox: "0 0 60 50" }, lung: { x: 35, y: 85, width: 52, height: 41, viewBox: "0 0 52 41" }, breast: { x: 26, y: 103, width: 70, height: 30, viewBox: "0 0 70 30" }, heart: { x: 50, y: 91, width: 22, height: 23, viewBox: "0 0 22 23" }, digestive: { x: 41, y: 119, width: 42, height: 86, viewBox: "0 0 42 86" }, kidney: { x: 49, y: 162, width: 24, height: 30, viewBox: "0 0 24 30" }, limbs: { x: 31, y: 146, width: 87, height: 203, viewBox: "0 0 87 203" }, integument: { x: 6, y: 91, width: 90, height: 235, viewBox: "0 0 90 235" }, head: { x: 35, y: 4, width: 52, height: 68, viewBox: "0 0 52 68" }, eye: { x: 69, y: 24, width: 12, height: 12, viewBox: "0 0 12 12" }, ear: { x: 34, y: 30, width: 13, height: 13, viewBox: "0 0 13 13" }, nervous: { x: 27, y: 4, width: 68, height: 185, viewBox: "0 0 68 185" }, voice: { x: 54, y: 56, width: 14, height: 22, viewBox: "0 0 14 22" }, metabolism: { x: 50, y: 128, width: 22, height: 21, viewBox: "0 0 22 21" }, cell: { x: 27, y: 185, width: 11, height: 18, viewBox: "0 0 11 18" }, endocrine: { x: 50, y: 64, width: 26, height: 99, viewBox: "0 0 26 99" }, neoplasm: { x: 81, y: 80, width: 11, height: 12, viewBox: "0 0 11 12" }, immune: { x: 57, y: 81, width: 30, height: 58, viewBox: "0 0 30 58" }, muscle: { x: 13, y: 79, width: 83, height: 182, viewBox: "0 0 83 182" }, prenatal: { x: 44, y: 170, width: 34, height: 24, viewBox: "0 0 34 24" }, blood: { x: 6, y: 182, width: 6, height: 10, viewBox: "0 0 6 10" }, }; const BACKGROUND_ORGANS: readonly OrganId[] = ["growth"] as const; const FOREGROUND_ORGANS: readonly OrganId[] = [ "constitutional", "thoracicCavity", "lung", "breast", "heart", "digestive", "kidney", "limbs", "integument", "head", "eye", "ear", "nervous", "voice", "metabolism", "cell", "endocrine", "neoplasm", "immune", "muscle", "prenatal", "blood", ] as const; type RenderEntry = { type: "organ"; organId: OrganId } | { type: "body" }; const MIN_ZOOM = 1; /** * HPO Visualizer - Interactive human organ visualization component * * Features: * - Displays organs as interactive SVG graphics * - Hover effect with visual highlighting * - Click to select/deselect organs * - Hover effects work independently even when an organ is selected * - Exposes hover and selection state via callbacks * - Zoom and pan support with mouse wheel and drag */ export function HpoVisualizer({ organs, visibleOrgans, hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, colorPalette: inputColorPalette, width = "100%", height = "100%", className, style, maxZoom = 5, wheelZoom = true, }: 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, wheelZoom, viewBox: BODY_VIEWBOX }); // Create strict color palette with all required colors and alpha values const colorPalette: StrictColorPalette = useMemo( () => createStrictColorPalette(inputColorPalette), [inputColorPalette], ); // Determine which organs are visible based on visibleOrgans prop // Priority: visibleOrgans > organs > all organs // - visibleOrgans (array): only the specified organs are visible // - visibleOrgans (empty array): no organs are visible // - visibleOrgans (undefined) + organs (array): organs from organs prop are visible // - both undefined: all organs are visible const visibleOrganIds = useMemo((): OrganId[] => { if (visibleOrgans !== undefined) { return visibleOrgans; } if (organs !== undefined) { return organs.map((o) => o.id); } return [...ORGAN_IDS]; }, [visibleOrgans, organs]); // Create organ config map for quick lookup const organConfigMap = useMemo((): Map => { const map = new Map(); if (organs) { for (const config of organs) { map.set(config.id, config); } } return map; }, [organs]); // Use the interaction hook const { handlers, isHovered, isSelected, state } = useOrganInteraction({ hoveredOrgan: controlledHovered, selectedOrgan: controlledSelected, onHover, onSelect, }); 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 = { display: "flex", justifyContent: "center", alignItems: "flex-end", userSelect: "none", position: "relative", width: width, height: height, ...containerStyleOverrides, // Apply overflow after style spread to ensure it takes precedence overflow: "clip", }; const contentStyle: CSSProperties = { display: "flex", justifyContent: "center", alignItems: "flex-end", width: "100%", height: "100%", 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", overflow: "visible", }; const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`; const centerX = BODY_VIEWBOX.width / 2; const centerY = BODY_VIEWBOX.height / 2; const zoomTransform = `translate(${centerX} ${centerY}) scale(${zoom}) translate(${-centerX} ${-centerY})`; // Helper function to render an organ const renderOrgan = (organId: OrganId, isVisible: boolean) => { const position = ORGAN_POSITIONS[organId]; const config = organConfigMap.get(organId); // Check if organ should be visible based on config style (different from visibleOrgans) if (config?.style?.visible === false) { return null; } const x = position.x; const y = position.y; const width = position.width; const height = position.height; return ( handlers.handleMouseEnter(organId)} onMouseLeave={handlers.handleMouseLeave} onClick={() => handlers.handleClick(organId)} x={x} y={y} width={width} height={height} viewBox={position.viewBox} showOutline={config?.showOutline} isVisible={isVisible} /> ); }; return (
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} >
Human organ visualizer {renderEntries.map((entry) => { if (entry.type === "body") { return ( ); } return renderOrgan(entry.organId, visibleOrganIds.includes(entry.organId)); })}
); }