hpo-react-visualizer 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -80,6 +80,82 @@ var ORGAN_NAMES_KO = {
80
80
  prenatal: "\uD0DC\uC544",
81
81
  blood: "\uD608\uC561"
82
82
  };
83
+ var HPO_LABELS = [
84
+ "others",
85
+ "Growth abnormality",
86
+ "Abnormality of the genitourinary system",
87
+ "Abnormality of the immune system",
88
+ "Abnormality of the digestive system",
89
+ "Abnormality of metabolism/homeostasis",
90
+ "Abnormality of head or neck",
91
+ "Abnormality of the musculoskeletal system",
92
+ "Abnormality of the nervous system",
93
+ "Abnormality of the respiratory system",
94
+ "Abnormality of the eye",
95
+ "Abnormality of the cardiovascular system",
96
+ "Abnormality of the ear",
97
+ "Abnormality of prenatal development or birth",
98
+ "Abnormality of the integument",
99
+ "Abnormality of the breast",
100
+ "Abnormality of the endocrine system",
101
+ "Abnormality of blood and blood-forming tissues",
102
+ "Abnormality of limbs",
103
+ "Abnormality of the voice",
104
+ "Constitutional symptom",
105
+ "Neoplasm",
106
+ "Abnormal cellular phenotype",
107
+ "Abnormality of the thoracic cavity"
108
+ ];
109
+ var HPO_LABEL_TO_ORGAN = {
110
+ "Growth abnormality": "growth",
111
+ "Abnormality of the genitourinary system": "kidney",
112
+ "Abnormality of the immune system": "immune",
113
+ "Abnormality of the digestive system": "digestive",
114
+ "Abnormality of metabolism/homeostasis": "metabolism",
115
+ "Abnormality of head or neck": "head",
116
+ "Abnormality of the musculoskeletal system": "muscle",
117
+ "Abnormality of the nervous system": "nervous",
118
+ "Abnormality of the respiratory system": "lung",
119
+ "Abnormality of the eye": "eye",
120
+ "Abnormality of the cardiovascular system": "heart",
121
+ "Abnormality of the ear": "ear",
122
+ "Abnormality of prenatal development or birth": "prenatal",
123
+ "Abnormality of the integument": "integument",
124
+ "Abnormality of the breast": "breast",
125
+ "Abnormality of the endocrine system": "endocrine",
126
+ "Abnormality of blood and blood-forming tissues": "blood",
127
+ "Abnormality of limbs": "limbs",
128
+ "Abnormality of the voice": "voice",
129
+ "Constitutional symptom": "constitutional",
130
+ Neoplasm: "neoplasm",
131
+ "Abnormal cellular phenotype": "cell",
132
+ "Abnormality of the thoracic cavity": "thoracicCavity"
133
+ };
134
+ var ORGAN_TO_HPO_LABEL = {
135
+ growth: "Growth abnormality",
136
+ kidney: "Abnormality of the genitourinary system",
137
+ immune: "Abnormality of the immune system",
138
+ digestive: "Abnormality of the digestive system",
139
+ metabolism: "Abnormality of metabolism/homeostasis",
140
+ head: "Abnormality of head or neck",
141
+ muscle: "Abnormality of the musculoskeletal system",
142
+ nervous: "Abnormality of the nervous system",
143
+ lung: "Abnormality of the respiratory system",
144
+ eye: "Abnormality of the eye",
145
+ heart: "Abnormality of the cardiovascular system",
146
+ ear: "Abnormality of the ear",
147
+ prenatal: "Abnormality of prenatal development or birth",
148
+ integument: "Abnormality of the integument",
149
+ breast: "Abnormality of the breast",
150
+ endocrine: "Abnormality of the endocrine system",
151
+ blood: "Abnormality of blood and blood-forming tissues",
152
+ limbs: "Abnormality of limbs",
153
+ voice: "Abnormality of the voice",
154
+ constitutional: "Constitutional symptom",
155
+ neoplasm: "Neoplasm",
156
+ cell: "Abnormal cellular phenotype",
157
+ thoracicCavity: "Abnormality of the thoracic cavity"
158
+ };
83
159
  var DEFAULT_COLOR_PALETTE = {
84
160
  blue: {
85
161
  100: "#DBEAFE",
@@ -1303,6 +1379,211 @@ function useOrganInteraction(options = {}) {
1303
1379
  isHovered
1304
1380
  };
1305
1381
  }
1382
+ function useZoom(options = {}) {
1383
+ const { minZoom = 1, maxZoom = 5, zoomStep = 0.5 } = options;
1384
+ const [zoom, setZoom] = react.useState(1);
1385
+ const [pan, setPan] = react.useState({ x: 0, y: 0 });
1386
+ const [isDragging, setIsDragging] = react.useState(false);
1387
+ const dragStartRef = react.useRef({ x: 0, y: 0 });
1388
+ const panStartRef = react.useRef({ x: 0, y: 0 });
1389
+ const containerRef = react.useRef(null);
1390
+ const clampZoom = react.useCallback(
1391
+ (value) => Math.max(minZoom, Math.min(maxZoom, value)),
1392
+ [minZoom, maxZoom]
1393
+ );
1394
+ const zoomIn = react.useCallback(() => {
1395
+ setZoom((prev) => clampZoom(prev + zoomStep));
1396
+ }, [clampZoom, zoomStep]);
1397
+ const zoomOut = react.useCallback(() => {
1398
+ setZoom((prev) => clampZoom(prev - zoomStep));
1399
+ }, [clampZoom, zoomStep]);
1400
+ const resetZoom = react.useCallback(() => {
1401
+ setZoom(1);
1402
+ setPan({ x: 0, y: 0 });
1403
+ }, []);
1404
+ react.useEffect(() => {
1405
+ const container = containerRef.current;
1406
+ if (!container) return;
1407
+ const wheelZoomStep = 0.1;
1408
+ const handleWheel = (e) => {
1409
+ e.preventDefault();
1410
+ const rect = container.getBoundingClientRect();
1411
+ const mouseX = e.clientX - rect.left - rect.width / 2;
1412
+ const mouseY = e.clientY - rect.top - rect.height / 2;
1413
+ const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep;
1414
+ setZoom((prevZoom) => {
1415
+ const newZoom = clampZoom(prevZoom + delta);
1416
+ if (newZoom === prevZoom) return prevZoom;
1417
+ setPan((prevPan) => {
1418
+ const contentX = (mouseX - prevPan.x) / prevZoom;
1419
+ const contentY = (mouseY - prevPan.y) / prevZoom;
1420
+ const newPanX = mouseX - contentX * newZoom;
1421
+ const newPanY = mouseY - contentY * newZoom;
1422
+ if (newZoom <= 1) {
1423
+ return { x: 0, y: 0 };
1424
+ }
1425
+ const maxPanX = rect.width * (newZoom - 1) / 2;
1426
+ const maxPanY = rect.height * (newZoom - 1) / 2;
1427
+ return {
1428
+ x: Math.max(-maxPanX, Math.min(maxPanX, newPanX)),
1429
+ y: Math.max(-maxPanY, Math.min(maxPanY, newPanY))
1430
+ };
1431
+ });
1432
+ return newZoom;
1433
+ });
1434
+ };
1435
+ container.addEventListener("wheel", handleWheel, { passive: false });
1436
+ return () => {
1437
+ container.removeEventListener("wheel", handleWheel);
1438
+ };
1439
+ }, [clampZoom]);
1440
+ const clampPan = react.useCallback((newPan, currentZoom) => {
1441
+ const container = containerRef.current;
1442
+ if (!container || currentZoom <= 1) {
1443
+ return { x: 0, y: 0 };
1444
+ }
1445
+ const containerWidth = container.clientWidth;
1446
+ const containerHeight = container.clientHeight;
1447
+ const maxPanX = containerWidth * (currentZoom - 1) / 2;
1448
+ const maxPanY = containerHeight * (currentZoom - 1) / 2;
1449
+ return {
1450
+ x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)),
1451
+ y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y))
1452
+ };
1453
+ }, []);
1454
+ react.useEffect(() => {
1455
+ setPan((currentPan) => clampPan(currentPan, zoom));
1456
+ }, [zoom, clampPan]);
1457
+ const handleMouseDown = react.useCallback(
1458
+ (e) => {
1459
+ if (e.button !== 0 || zoom <= 1) return;
1460
+ setIsDragging(true);
1461
+ dragStartRef.current = { x: e.clientX, y: e.clientY };
1462
+ panStartRef.current = { ...pan };
1463
+ e.preventDefault();
1464
+ },
1465
+ [pan, zoom]
1466
+ );
1467
+ const handleMouseMove = react.useCallback(
1468
+ (e) => {
1469
+ if (!isDragging || zoom <= 1) return;
1470
+ const dx = e.clientX - dragStartRef.current.x;
1471
+ const dy = e.clientY - dragStartRef.current.y;
1472
+ const newPan = {
1473
+ x: panStartRef.current.x + dx,
1474
+ y: panStartRef.current.y + dy
1475
+ };
1476
+ setPan(clampPan(newPan, zoom));
1477
+ },
1478
+ [isDragging, zoom, clampPan]
1479
+ );
1480
+ const handleMouseUp = react.useCallback(() => {
1481
+ setIsDragging(false);
1482
+ }, []);
1483
+ const isDefaultZoom = zoom === 1 && pan.x === 0 && pan.y === 0;
1484
+ return {
1485
+ zoom,
1486
+ pan,
1487
+ zoomIn,
1488
+ zoomOut,
1489
+ resetZoom,
1490
+ handleMouseDown,
1491
+ handleMouseMove,
1492
+ handleMouseUp,
1493
+ isDragging,
1494
+ isDefaultZoom,
1495
+ containerRef
1496
+ };
1497
+ }
1498
+ var buttonBaseStyle = {
1499
+ width: 32,
1500
+ height: 32,
1501
+ display: "flex",
1502
+ alignItems: "center",
1503
+ justifyContent: "center",
1504
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
1505
+ border: "1px solid #d1d5db",
1506
+ borderRadius: 6,
1507
+ cursor: "pointer",
1508
+ fontSize: 18,
1509
+ fontWeight: "bold",
1510
+ color: "#374151",
1511
+ transition: "background-color 0.15s ease",
1512
+ userSelect: "none"
1513
+ };
1514
+ var buttonDisabledStyle = {
1515
+ ...buttonBaseStyle,
1516
+ opacity: 0.4,
1517
+ cursor: "not-allowed"
1518
+ };
1519
+ var getContainerStyle = (isVisible) => ({
1520
+ position: "absolute",
1521
+ bottom: 12,
1522
+ right: 12,
1523
+ display: "flex",
1524
+ flexDirection: "column",
1525
+ gap: 4,
1526
+ zIndex: 10,
1527
+ opacity: isVisible ? 1 : 0,
1528
+ transition: "opacity 0.2s ease-in-out",
1529
+ pointerEvents: isVisible ? "auto" : "none"
1530
+ });
1531
+ var getResetContainerStyle = (isVisible) => ({
1532
+ position: "absolute",
1533
+ bottom: 12,
1534
+ left: 12,
1535
+ zIndex: 10,
1536
+ opacity: isVisible ? 1 : 0,
1537
+ transition: "opacity 0.2s ease-in-out",
1538
+ pointerEvents: isVisible ? "auto" : "none"
1539
+ });
1540
+ var resetButtonStyle = {
1541
+ ...buttonBaseStyle,
1542
+ width: "auto",
1543
+ padding: "6px 12px",
1544
+ fontSize: 12,
1545
+ fontWeight: 500
1546
+ };
1547
+ function ZoomControls({
1548
+ onZoomIn,
1549
+ onZoomOut,
1550
+ onReset,
1551
+ showResetButton,
1552
+ zoom,
1553
+ minZoom,
1554
+ maxZoom,
1555
+ isVisible
1556
+ }) {
1557
+ const isMinZoom = zoom <= minZoom;
1558
+ const isMaxZoom = zoom >= maxZoom;
1559
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1560
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: getContainerStyle(isVisible), children: [
1561
+ /* @__PURE__ */ jsxRuntime.jsx(
1562
+ "button",
1563
+ {
1564
+ type: "button",
1565
+ onClick: onZoomIn,
1566
+ disabled: isMaxZoom,
1567
+ style: isMaxZoom ? buttonDisabledStyle : buttonBaseStyle,
1568
+ "aria-label": "Zoom in",
1569
+ children: "+"
1570
+ }
1571
+ ),
1572
+ /* @__PURE__ */ jsxRuntime.jsx(
1573
+ "button",
1574
+ {
1575
+ type: "button",
1576
+ onClick: onZoomOut,
1577
+ disabled: isMinZoom,
1578
+ style: isMinZoom ? buttonDisabledStyle : buttonBaseStyle,
1579
+ "aria-label": "Zoom out",
1580
+ children: "\u2212"
1581
+ }
1582
+ )
1583
+ ] }),
1584
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: getResetContainerStyle(isVisible && showResetButton), children: /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", onClick: onReset, style: resetButtonStyle, "aria-label": "Reset zoom", children: "\u21BA" }) })
1585
+ ] });
1586
+ }
1306
1587
  var ORGAN_POSITIONS = {
1307
1588
  growth: { x: 104, y: 1, width: 12, height: 349, viewBox: "0 0 12 349" },
1308
1589
  constitutional: { x: 0, y: 0, width: 122, height: 358, viewBox: "0 0 122 358" },
@@ -1353,6 +1634,7 @@ var FOREGROUND_ORGANS = [
1353
1634
  "prenatal",
1354
1635
  "blood"
1355
1636
  ];
1637
+ var MIN_ZOOM = 1;
1356
1638
  function HpoVisualizer({
1357
1639
  organs,
1358
1640
  visibleOrgans,
@@ -1364,9 +1646,24 @@ function HpoVisualizer({
1364
1646
  width = BODY_VIEWBOX.width,
1365
1647
  height = BODY_VIEWBOX.height,
1366
1648
  className,
1367
- style
1649
+ style,
1650
+ maxZoom = 5
1368
1651
  }) {
1369
1652
  const visualizerID = react.useId();
1653
+ const [isHovering, setIsHovering] = react.useState(false);
1654
+ const {
1655
+ zoom,
1656
+ pan,
1657
+ zoomIn,
1658
+ zoomOut,
1659
+ resetZoom,
1660
+ handleMouseDown,
1661
+ handleMouseMove,
1662
+ handleMouseUp,
1663
+ isDragging,
1664
+ isDefaultZoom,
1665
+ containerRef
1666
+ } = useZoom({ minZoom: MIN_ZOOM, maxZoom });
1370
1667
  const colorPalette = react.useMemo(
1371
1668
  () => createStrictColorPalette(inputColorPalette),
1372
1669
  [inputColorPalette]
@@ -1403,7 +1700,19 @@ function HpoVisualizer({
1403
1700
  position: "relative",
1404
1701
  width,
1405
1702
  height,
1406
- ...style
1703
+ ...style,
1704
+ // Apply overflow after style spread to ensure it takes precedence
1705
+ // Use 'clip' instead of 'hidden' to respect padding
1706
+ overflow: "clip"
1707
+ };
1708
+ const contentStyle = {
1709
+ position: "relative",
1710
+ width: "100%",
1711
+ height: "100%",
1712
+ transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
1713
+ transformOrigin: "center center",
1714
+ cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
1715
+ transition: isDragging ? "none" : "transform 0.1s ease-out"
1407
1716
  };
1408
1717
  const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
1409
1718
  const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
@@ -1440,43 +1749,83 @@ function HpoVisualizer({
1440
1749
  `${visualizerID}-${organId}`
1441
1750
  );
1442
1751
  };
1443
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, style: containerStyle, children: [
1444
- BACKGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId))),
1445
- /* @__PURE__ */ jsxRuntime.jsxs(
1446
- "svg",
1447
- {
1448
- x: translateX,
1449
- y: "0",
1450
- width,
1451
- height,
1452
- viewBox,
1453
- style: { position: "absolute", top: "0", left: "0" },
1454
- pointerEvents: "none",
1455
- children: [
1456
- /* @__PURE__ */ jsxRuntime.jsx("title", { children: "Human body" }),
1457
- /* @__PURE__ */ jsxRuntime.jsx(
1458
- Body,
1752
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1753
+ "div",
1754
+ {
1755
+ ref: containerRef,
1756
+ className,
1757
+ style: containerStyle,
1758
+ onMouseEnter: () => setIsHovering(true),
1759
+ onMouseLeave: () => {
1760
+ setIsHovering(false);
1761
+ handleMouseUp();
1762
+ },
1763
+ onMouseDown: handleMouseDown,
1764
+ onMouseMove: handleMouseMove,
1765
+ onMouseUp: handleMouseUp,
1766
+ role: "application",
1767
+ tabIndex: 0,
1768
+ children: [
1769
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: contentStyle, children: [
1770
+ BACKGROUND_ORGANS.map(
1771
+ (organId) => renderOrgan(organId, visibleOrganIds.includes(organId))
1772
+ ),
1773
+ /* @__PURE__ */ jsxRuntime.jsxs(
1774
+ "svg",
1459
1775
  {
1460
- colorScale: colorPalette[DEFAULT_COLOR_NAME],
1461
- style: {
1462
- fill: "#fff"
1463
- }
1776
+ x: translateX,
1777
+ y: "0",
1778
+ width,
1779
+ height,
1780
+ viewBox,
1781
+ style: { position: "absolute", top: "0", left: "0" },
1782
+ pointerEvents: "none",
1783
+ children: [
1784
+ /* @__PURE__ */ jsxRuntime.jsx("title", { children: "Human body" }),
1785
+ /* @__PURE__ */ jsxRuntime.jsx(
1786
+ Body,
1787
+ {
1788
+ colorScale: colorPalette[DEFAULT_COLOR_NAME],
1789
+ style: {
1790
+ fill: "#fff"
1791
+ }
1792
+ }
1793
+ )
1794
+ ]
1464
1795
  }
1796
+ ),
1797
+ FOREGROUND_ORGANS.map(
1798
+ (organId) => renderOrgan(organId, visibleOrganIds.includes(organId))
1465
1799
  )
1466
- ]
1467
- }
1468
- ),
1469
- FOREGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))
1470
- ] });
1800
+ ] }),
1801
+ /* @__PURE__ */ jsxRuntime.jsx(
1802
+ ZoomControls,
1803
+ {
1804
+ onZoomIn: zoomIn,
1805
+ onZoomOut: zoomOut,
1806
+ onReset: resetZoom,
1807
+ showResetButton: !isDefaultZoom,
1808
+ zoom,
1809
+ minZoom: MIN_ZOOM,
1810
+ maxZoom,
1811
+ isVisible: isHovering
1812
+ }
1813
+ )
1814
+ ]
1815
+ }
1816
+ );
1471
1817
  }
1472
1818
 
1473
1819
  exports.ANIMATION_DURATION_MS = ANIMATION_DURATION_MS;
1474
1820
  exports.DEFAULT_COLOR_PALETTE = DEFAULT_COLOR_PALETTE;
1821
+ exports.HPO_LABELS = HPO_LABELS;
1822
+ exports.HPO_LABEL_TO_ORGAN = HPO_LABEL_TO_ORGAN;
1475
1823
  exports.HpoVisualizer = HpoVisualizer;
1476
1824
  exports.ORGAN_COMPONENTS = ORGAN_COMPONENTS;
1477
1825
  exports.ORGAN_IDS = ORGAN_IDS;
1478
1826
  exports.ORGAN_NAMES_EN = ORGAN_NAMES_EN;
1479
1827
  exports.ORGAN_NAMES_KO = ORGAN_NAMES_KO;
1828
+ exports.ORGAN_TO_HPO_LABEL = ORGAN_TO_HPO_LABEL;
1480
1829
  exports.OrganSvg = OrganSvg;
1481
1830
  exports.useOrganInteraction = useOrganInteraction;
1482
1831
  //# sourceMappingURL=index.cjs.map