hpo-react-visualizer 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs 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",
@@ -116,7 +192,19 @@ var DEFAULT_COLOR_NAME = "blue";
116
192
  var DEFAULT_STROKE_COLOR = "#ff142d";
117
193
  var DEFAULT_STROKE_WIDTH = 0.5;
118
194
  var ANIMATION_DURATION_MS = 150;
119
- var TRANSITION_STYLE = `all ${ANIMATION_DURATION_MS}ms ease-out`;
195
+ var TRANSITION_PROPERTIES = [
196
+ "fill",
197
+ "stroke",
198
+ "stroke-width",
199
+ "opacity",
200
+ "filter",
201
+ "stop-color",
202
+ "stop-opacity",
203
+ "visibility"
204
+ ];
205
+ var TRANSITION_STYLE = TRANSITION_PROPERTIES.map(
206
+ (property) => `${property} ${ANIMATION_DURATION_MS}ms ease-out`
207
+ ).join(", ");
120
208
  var BODY_VIEWBOX = {
121
209
  width: 122,
122
210
  height: 358
@@ -148,6 +236,20 @@ function createStrictColorPalette(palette) {
148
236
  return result;
149
237
  }, {});
150
238
  }
239
+
240
+ // src/lib/organControlState.ts
241
+ var createUniformOrganColorSchemes = (organIds, scheme) => {
242
+ return organIds.reduce(
243
+ (acc, organId) => {
244
+ acc[organId] = scheme;
245
+ return acc;
246
+ },
247
+ {}
248
+ );
249
+ };
250
+ var createOrganOutlineSet = (organIds, enabled) => {
251
+ return enabled ? new Set(organIds) : /* @__PURE__ */ new Set();
252
+ };
151
253
  function Blood({ style, colorScale, isActive = false, className }) {
152
254
  const defaultColor = colorScale[200];
153
255
  const activeColor = colorScale[300];
@@ -985,18 +1087,22 @@ function Neoplasm({ style, colorScale, isActive = false, className }) {
985
1087
  const defaultColor = colorScale[100];
986
1088
  const activeColor = colorScale[300];
987
1089
  const fill = style?.fill ?? (isActive ? activeColor : defaultColor);
988
- return /* @__PURE__ */ jsxRuntime.jsx("g", { className, "data-organ": "neoplasm", children: /* @__PURE__ */ jsxRuntime.jsx(
989
- "path",
990
- {
991
- d: NEOPLASM_PATH,
992
- fill,
993
- stroke: style?.stroke,
994
- strokeWidth: style?.strokeWidth,
995
- style: { transition: TRANSITION_STYLE }
996
- }
997
- ) });
1090
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { className, "data-organ": "neoplasm", children: [
1091
+ /* @__PURE__ */ jsxRuntime.jsx(
1092
+ "path",
1093
+ {
1094
+ d: NEOPLASM_PATH,
1095
+ fill,
1096
+ stroke: style?.stroke,
1097
+ strokeWidth: style?.strokeWidth,
1098
+ style: { transition: TRANSITION_STYLE }
1099
+ }
1100
+ ),
1101
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: OUTLINE_NEOPLASM_PATH, fill: "transparent", style: { transition: TRANSITION_STYLE } })
1102
+ ] });
998
1103
  }
999
1104
  var 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.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";
1105
+ var 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";
1000
1106
  function Nervous({ style, colorScale, isActive = false, className }) {
1001
1107
  const defaultColor = colorScale[100];
1002
1108
  const activeColor = colorScale[200];
@@ -1161,25 +1267,21 @@ function OrganSvg({
1161
1267
  };
1162
1268
  return mergeStyles(userStyle, strokeStyle);
1163
1269
  }, [config?.style, showOutline]);
1164
- const svgStyle = {
1165
- position: "absolute",
1166
- left: x,
1167
- top: y,
1168
- transition: `${TRANSITION_STYLE}, visibility 0s`,
1169
- zIndex: isSelected ? 1 : 0
1270
+ const [minX = 0, minY = 0, viewBoxWidth, viewBoxHeight] = viewBox.split(" ").map(Number);
1271
+ const scaleX = viewBoxWidth ? width / viewBoxWidth : 1;
1272
+ const scaleY = viewBoxHeight ? height / viewBoxHeight : 1;
1273
+ const transform = `translate(${x} ${y}) scale(${scaleX} ${scaleY}) translate(${-minX} ${-minY})`;
1274
+ const groupStyle = {
1275
+ transition: `${TRANSITION_STYLE}, visibility 0s`
1170
1276
  };
1171
1277
  const filter = isActive ? "blur(1px)" : void 0;
1172
1278
  return /* @__PURE__ */ jsxRuntime.jsxs(
1173
- "svg",
1279
+ "g",
1174
1280
  {
1175
- width,
1176
- height,
1177
- viewBox,
1178
- style: svgStyle,
1281
+ transform,
1282
+ style: groupStyle,
1179
1283
  filter,
1180
1284
  "aria-label": organId,
1181
- overflow: "visible",
1182
- pointerEvents: "none",
1183
1285
  opacity: isVisible ? 1 : 0,
1184
1286
  visibility: isVisible ? "visible" : "hidden",
1185
1287
  children: [
@@ -1265,14 +1367,8 @@ function useOrganInteraction(options = {}) {
1265
1367
  setInternalSelected(newSelected);
1266
1368
  }
1267
1369
  onSelect?.(newSelected);
1268
- if (newSelected === null) {
1269
- if (!isHoverControlled) {
1270
- setInternalHovered(null);
1271
- }
1272
- onHover?.(null);
1273
- }
1274
1370
  },
1275
- [selectedOrgan, isSelectControlled, isHoverControlled, onSelect, onHover]
1371
+ [selectedOrgan, isSelectControlled, onSelect]
1276
1372
  );
1277
1373
  const state = react.useMemo(
1278
1374
  () => ({
@@ -1303,6 +1399,338 @@ function useOrganInteraction(options = {}) {
1303
1399
  isHovered
1304
1400
  };
1305
1401
  }
1402
+ var ZOOM_ANIMATION_MS = 180;
1403
+ var easeOutCubic = (t) => 1 - (1 - t) ** 3;
1404
+ function useZoom(options = {}) {
1405
+ const { minZoom = 1, maxZoom = 5, zoomStep = 0.5, wheelZoom = true, viewBox } = options;
1406
+ const [zoom, setZoom] = react.useState(1);
1407
+ const [pan, setPan] = react.useState({ x: 0, y: 0 });
1408
+ const [isDragging, setIsDragging] = react.useState(false);
1409
+ const dragStartRef = react.useRef({ x: 0, y: 0 });
1410
+ const panStartRef = react.useRef({ x: 0, y: 0 });
1411
+ const containerRef = react.useRef(null);
1412
+ const zoomRef = react.useRef(1);
1413
+ const panRef = react.useRef({ x: 0, y: 0 });
1414
+ const animationRef = react.useRef(null);
1415
+ const clampZoom = react.useCallback(
1416
+ (value) => Math.max(minZoom, Math.min(maxZoom, value)),
1417
+ [minZoom, maxZoom]
1418
+ );
1419
+ const stopAnimation = react.useCallback(() => {
1420
+ if (animationRef.current !== null) {
1421
+ cancelAnimationFrame(animationRef.current);
1422
+ animationRef.current = null;
1423
+ }
1424
+ }, []);
1425
+ const animateZoom = react.useCallback(
1426
+ (targetZoom, options2) => {
1427
+ const { targetPan, durationMs = ZOOM_ANIMATION_MS } = options2 ?? {};
1428
+ stopAnimation();
1429
+ const startZoom = zoomRef.current;
1430
+ const clampedTargetZoom = clampZoom(targetZoom);
1431
+ const startPan = panRef.current;
1432
+ const hasPanTarget = targetPan !== void 0;
1433
+ const finalPan = targetPan ?? startPan;
1434
+ if (durationMs <= 0 || clampedTargetZoom === startZoom && (!hasPanTarget || finalPan.x === startPan.x && finalPan.y === startPan.y)) {
1435
+ setZoom(clampedTargetZoom);
1436
+ zoomRef.current = clampedTargetZoom;
1437
+ if (hasPanTarget) {
1438
+ setPan(finalPan);
1439
+ panRef.current = finalPan;
1440
+ }
1441
+ return;
1442
+ }
1443
+ const startTime = performance.now();
1444
+ const step = (now) => {
1445
+ const elapsed = now - startTime;
1446
+ const t = Math.min(elapsed / durationMs, 1);
1447
+ const eased = easeOutCubic(t);
1448
+ const nextZoom = startZoom + (clampedTargetZoom - startZoom) * eased;
1449
+ zoomRef.current = nextZoom;
1450
+ setZoom(nextZoom);
1451
+ if (hasPanTarget) {
1452
+ const nextPan = {
1453
+ x: startPan.x + (finalPan.x - startPan.x) * eased,
1454
+ y: startPan.y + (finalPan.y - startPan.y) * eased
1455
+ };
1456
+ panRef.current = nextPan;
1457
+ setPan(nextPan);
1458
+ }
1459
+ if (t < 1) {
1460
+ animationRef.current = requestAnimationFrame(step);
1461
+ } else {
1462
+ animationRef.current = null;
1463
+ setZoom(clampedTargetZoom);
1464
+ zoomRef.current = clampedTargetZoom;
1465
+ if (hasPanTarget) {
1466
+ setPan(finalPan);
1467
+ panRef.current = finalPan;
1468
+ }
1469
+ }
1470
+ };
1471
+ animationRef.current = requestAnimationFrame(step);
1472
+ },
1473
+ [clampZoom, stopAnimation]
1474
+ );
1475
+ const zoomIn = react.useCallback(() => {
1476
+ const nextZoom = clampZoom(zoomRef.current + zoomStep);
1477
+ animateZoom(nextZoom);
1478
+ }, [animateZoom, clampZoom, zoomStep]);
1479
+ const zoomOut = react.useCallback(() => {
1480
+ const nextZoom = clampZoom(zoomRef.current - zoomStep);
1481
+ animateZoom(nextZoom);
1482
+ }, [animateZoom, clampZoom, zoomStep]);
1483
+ const resetZoom = react.useCallback(() => {
1484
+ animateZoom(1, { targetPan: { x: 0, y: 0 } });
1485
+ }, [animateZoom]);
1486
+ const getViewBoxSize = react.useCallback(
1487
+ (container) => {
1488
+ if (viewBox?.width && viewBox?.height) {
1489
+ return { width: viewBox.width, height: viewBox.height };
1490
+ }
1491
+ const rect = container.getBoundingClientRect();
1492
+ return { width: rect.width || 1, height: rect.height || 1 };
1493
+ },
1494
+ [viewBox?.width, viewBox?.height]
1495
+ );
1496
+ const getViewBoxMetrics = react.useCallback(
1497
+ (container) => {
1498
+ const rect = container.getBoundingClientRect();
1499
+ const { width: viewBoxWidth, height: viewBoxHeight } = getViewBoxSize(container);
1500
+ const safeWidth = viewBoxWidth || 1;
1501
+ const safeHeight = viewBoxHeight || 1;
1502
+ const scale = rect.width > 0 && rect.height > 0 ? Math.min(rect.width / safeWidth, rect.height / safeHeight) : 0;
1503
+ const contentWidth = safeWidth * scale;
1504
+ const contentHeight = safeHeight * scale;
1505
+ const offsetX = (rect.width - contentWidth) / 2;
1506
+ const offsetY = (rect.height - contentHeight) / 2;
1507
+ return { rect, viewBoxWidth: safeWidth, viewBoxHeight: safeHeight, scale, offsetX, offsetY };
1508
+ },
1509
+ [getViewBoxSize]
1510
+ );
1511
+ const getPointerPosition = react.useCallback(
1512
+ (container, clientX, clientY) => {
1513
+ const ctm = container.getScreenCTM();
1514
+ if (ctm) {
1515
+ if (typeof DOMPoint !== "undefined") {
1516
+ const point = new DOMPoint(clientX, clientY);
1517
+ const { x, y } = point.matrixTransform(ctm.inverse());
1518
+ return { x, y };
1519
+ }
1520
+ if ("createSVGPoint" in container) {
1521
+ const point = container.createSVGPoint();
1522
+ point.x = clientX;
1523
+ point.y = clientY;
1524
+ const { x, y } = point.matrixTransform(ctm.inverse());
1525
+ return { x, y };
1526
+ }
1527
+ }
1528
+ const { rect, scale, offsetX, offsetY } = getViewBoxMetrics(container);
1529
+ if (scale <= 0) return null;
1530
+ return {
1531
+ x: (clientX - rect.left - offsetX) / scale,
1532
+ y: (clientY - rect.top - offsetY) / scale
1533
+ };
1534
+ },
1535
+ [getViewBoxMetrics]
1536
+ );
1537
+ const clampPan = react.useCallback(
1538
+ (newPan, currentZoom, viewBoxWidth, viewBoxHeight) => {
1539
+ if (currentZoom <= 1) {
1540
+ return { x: 0, y: 0 };
1541
+ }
1542
+ const maxPanX = viewBoxWidth * (currentZoom - 1) / 2;
1543
+ const maxPanY = viewBoxHeight * (currentZoom - 1) / 2;
1544
+ return {
1545
+ x: Math.max(-maxPanX, Math.min(maxPanX, newPan.x)),
1546
+ y: Math.max(-maxPanY, Math.min(maxPanY, newPan.y))
1547
+ };
1548
+ },
1549
+ []
1550
+ );
1551
+ react.useEffect(() => {
1552
+ zoomRef.current = zoom;
1553
+ }, [zoom]);
1554
+ react.useEffect(() => {
1555
+ panRef.current = pan;
1556
+ }, [pan]);
1557
+ react.useEffect(() => {
1558
+ return () => stopAnimation();
1559
+ }, [stopAnimation]);
1560
+ react.useEffect(() => {
1561
+ const container = containerRef.current;
1562
+ if (!container || !wheelZoom) return;
1563
+ const wheelZoomStep = 0.1;
1564
+ const handleWheel = (e) => {
1565
+ e.preventDefault();
1566
+ stopAnimation();
1567
+ const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container);
1568
+ const pointer = getPointerPosition(container, e.clientX, e.clientY);
1569
+ if (!pointer) return;
1570
+ const { x: mouseX, y: mouseY } = pointer;
1571
+ const centerX = viewBoxWidth / 2;
1572
+ const centerY = viewBoxHeight / 2;
1573
+ const delta = e.deltaY > 0 ? -wheelZoomStep : wheelZoomStep;
1574
+ const currentZoom = zoomRef.current;
1575
+ const nextZoom = clampZoom(currentZoom + delta);
1576
+ if (nextZoom === currentZoom) return;
1577
+ const zoomRatio = nextZoom / currentZoom;
1578
+ const nextPan = {
1579
+ x: (mouseX - centerX) * (1 - zoomRatio) + panRef.current.x * zoomRatio,
1580
+ y: (mouseY - centerY) * (1 - zoomRatio) + panRef.current.y * zoomRatio
1581
+ };
1582
+ const clampedPan = clampPan(nextPan, nextZoom, viewBoxWidth, viewBoxHeight);
1583
+ zoomRef.current = nextZoom;
1584
+ panRef.current = clampedPan;
1585
+ setZoom(nextZoom);
1586
+ setPan(clampedPan);
1587
+ };
1588
+ container.addEventListener("wheel", handleWheel, { passive: false });
1589
+ return () => {
1590
+ container.removeEventListener("wheel", handleWheel);
1591
+ };
1592
+ }, [clampZoom, wheelZoom, getViewBoxMetrics, getPointerPosition, clampPan, stopAnimation]);
1593
+ react.useEffect(() => {
1594
+ const container = containerRef.current;
1595
+ if (!container) return;
1596
+ const { viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container);
1597
+ setPan((currentPan) => clampPan(currentPan, zoom, viewBoxWidth, viewBoxHeight));
1598
+ }, [zoom, clampPan, getViewBoxMetrics]);
1599
+ const handleMouseDown = react.useCallback(
1600
+ (e) => {
1601
+ if (e.button !== 0 || zoom <= 1) return;
1602
+ stopAnimation();
1603
+ setIsDragging(true);
1604
+ dragStartRef.current = { x: e.clientX, y: e.clientY };
1605
+ panStartRef.current = { ...pan };
1606
+ e.preventDefault();
1607
+ },
1608
+ [pan, zoom, stopAnimation]
1609
+ );
1610
+ const handleMouseMove = react.useCallback(
1611
+ (e) => {
1612
+ if (!isDragging || zoom <= 1) return;
1613
+ const container = containerRef.current;
1614
+ if (!container) return;
1615
+ const { scale, viewBoxWidth, viewBoxHeight } = getViewBoxMetrics(container);
1616
+ if (scale <= 0) return;
1617
+ const dx = e.clientX - dragStartRef.current.x;
1618
+ const dy = e.clientY - dragStartRef.current.y;
1619
+ const newPan = {
1620
+ x: panStartRef.current.x + dx / scale,
1621
+ y: panStartRef.current.y + dy / scale
1622
+ };
1623
+ setPan(clampPan(newPan, zoom, viewBoxWidth, viewBoxHeight));
1624
+ },
1625
+ [isDragging, zoom, clampPan, getViewBoxMetrics]
1626
+ );
1627
+ const handleMouseUp = react.useCallback(() => {
1628
+ setIsDragging(false);
1629
+ }, []);
1630
+ const isDefaultZoom = zoom === 1 && pan.x === 0 && pan.y === 0;
1631
+ return {
1632
+ zoom,
1633
+ pan,
1634
+ zoomIn,
1635
+ zoomOut,
1636
+ resetZoom,
1637
+ handleMouseDown,
1638
+ handleMouseMove,
1639
+ handleMouseUp,
1640
+ isDragging,
1641
+ isDefaultZoom,
1642
+ containerRef
1643
+ };
1644
+ }
1645
+ var buttonBaseStyle = {
1646
+ width: 32,
1647
+ height: 32,
1648
+ display: "flex",
1649
+ alignItems: "center",
1650
+ justifyContent: "center",
1651
+ backgroundColor: "rgba(255, 255, 255, 0.9)",
1652
+ border: "1px solid #d1d5db",
1653
+ borderRadius: 6,
1654
+ cursor: "pointer",
1655
+ fontSize: 18,
1656
+ fontWeight: "bold",
1657
+ color: "#374151",
1658
+ transition: "background-color 0.15s ease",
1659
+ userSelect: "none"
1660
+ };
1661
+ var buttonDisabledStyle = {
1662
+ ...buttonBaseStyle,
1663
+ opacity: 0.4,
1664
+ cursor: "not-allowed"
1665
+ };
1666
+ var getContainerStyle = (isVisible) => ({
1667
+ position: "absolute",
1668
+ bottom: 12,
1669
+ right: 12,
1670
+ display: "flex",
1671
+ flexDirection: "column",
1672
+ gap: 4,
1673
+ zIndex: 10,
1674
+ opacity: isVisible ? 1 : 0,
1675
+ transition: "opacity 0.2s ease-in-out",
1676
+ pointerEvents: isVisible ? "auto" : "none"
1677
+ });
1678
+ var getResetContainerStyle = (isVisible) => ({
1679
+ position: "absolute",
1680
+ bottom: 12,
1681
+ left: 12,
1682
+ zIndex: 10,
1683
+ opacity: isVisible ? 1 : 0,
1684
+ transition: "opacity 0.2s ease-in-out",
1685
+ pointerEvents: isVisible ? "auto" : "none"
1686
+ });
1687
+ var resetButtonStyle = {
1688
+ ...buttonBaseStyle,
1689
+ width: "auto",
1690
+ padding: "6px 12px",
1691
+ fontSize: 12,
1692
+ fontWeight: 500
1693
+ };
1694
+ function ZoomControls({
1695
+ onZoomIn,
1696
+ onZoomOut,
1697
+ onReset,
1698
+ showResetButton,
1699
+ zoom,
1700
+ minZoom,
1701
+ maxZoom,
1702
+ isVisible
1703
+ }) {
1704
+ const isMinZoom = zoom <= minZoom;
1705
+ const isMaxZoom = zoom >= maxZoom;
1706
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1707
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: getContainerStyle(isVisible), children: [
1708
+ /* @__PURE__ */ jsxRuntime.jsx(
1709
+ "button",
1710
+ {
1711
+ type: "button",
1712
+ onClick: onZoomIn,
1713
+ disabled: isMaxZoom,
1714
+ style: isMaxZoom ? buttonDisabledStyle : buttonBaseStyle,
1715
+ "aria-label": "Zoom in",
1716
+ children: "+"
1717
+ }
1718
+ ),
1719
+ /* @__PURE__ */ jsxRuntime.jsx(
1720
+ "button",
1721
+ {
1722
+ type: "button",
1723
+ onClick: onZoomOut,
1724
+ disabled: isMinZoom,
1725
+ style: isMinZoom ? buttonDisabledStyle : buttonBaseStyle,
1726
+ "aria-label": "Zoom out",
1727
+ children: "\u2212"
1728
+ }
1729
+ )
1730
+ ] }),
1731
+ /* @__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" }) })
1732
+ ] });
1733
+ }
1306
1734
  var ORGAN_POSITIONS = {
1307
1735
  growth: { x: 104, y: 1, width: 12, height: 349, viewBox: "0 0 12 349" },
1308
1736
  constitutional: { x: 0, y: 0, width: 122, height: 358, viewBox: "0 0 122 358" },
@@ -1353,6 +1781,7 @@ var FOREGROUND_ORGANS = [
1353
1781
  "prenatal",
1354
1782
  "blood"
1355
1783
  ];
1784
+ var MIN_ZOOM = 1;
1356
1785
  function HpoVisualizer({
1357
1786
  organs,
1358
1787
  visibleOrgans,
@@ -1361,12 +1790,28 @@ function HpoVisualizer({
1361
1790
  onHover,
1362
1791
  onSelect,
1363
1792
  colorPalette: inputColorPalette,
1364
- width = BODY_VIEWBOX.width,
1365
- height = BODY_VIEWBOX.height,
1793
+ width = "100%",
1794
+ height = "100%",
1366
1795
  className,
1367
- style
1796
+ style,
1797
+ maxZoom = 5,
1798
+ wheelZoom = true
1368
1799
  }) {
1369
1800
  const visualizerID = react.useId();
1801
+ const [isHovering, setIsHovering] = react.useState(false);
1802
+ const {
1803
+ zoom,
1804
+ pan,
1805
+ zoomIn,
1806
+ zoomOut,
1807
+ resetZoom,
1808
+ handleMouseDown,
1809
+ handleMouseMove,
1810
+ handleMouseUp,
1811
+ isDragging,
1812
+ isDefaultZoom,
1813
+ containerRef
1814
+ } = useZoom({ minZoom: MIN_ZOOM, maxZoom, wheelZoom, viewBox: BODY_VIEWBOX });
1370
1815
  const colorPalette = react.useMemo(
1371
1816
  () => createStrictColorPalette(inputColorPalette),
1372
1817
  [inputColorPalette]
@@ -1389,12 +1834,48 @@ function HpoVisualizer({
1389
1834
  }
1390
1835
  return map;
1391
1836
  }, [organs]);
1392
- const { handlers, isHovered, isSelected } = useOrganInteraction({
1837
+ const { handlers, isHovered, isSelected, state } = useOrganInteraction({
1393
1838
  hoveredOrgan: controlledHovered,
1394
1839
  selectedOrgan: controlledSelected,
1395
1840
  onHover,
1396
1841
  onSelect
1397
1842
  });
1843
+ const selectedOrganId = state.selectedOrgan;
1844
+ const renderEntries = react.useMemo(() => {
1845
+ const base = [
1846
+ ...BACKGROUND_ORGANS.map((organId) => ({ type: "organ", organId })),
1847
+ { type: "body" },
1848
+ ...FOREGROUND_ORGANS.map((organId) => ({ type: "organ", organId }))
1849
+ ];
1850
+ if (!selectedOrganId) {
1851
+ return base;
1852
+ }
1853
+ const selectedIndex = base.findIndex(
1854
+ (entry) => entry.type === "organ" && entry.organId === selectedOrganId
1855
+ );
1856
+ if (selectedIndex === -1) {
1857
+ return base;
1858
+ }
1859
+ const selectedEntry = base[selectedIndex];
1860
+ if (!selectedEntry || selectedEntry.type !== "organ") {
1861
+ return base;
1862
+ }
1863
+ return [...base.slice(0, selectedIndex), ...base.slice(selectedIndex + 1), selectedEntry];
1864
+ }, [selectedOrganId]);
1865
+ const {
1866
+ padding,
1867
+ paddingTop,
1868
+ paddingRight,
1869
+ paddingBottom,
1870
+ paddingLeft,
1871
+ paddingInline,
1872
+ paddingInlineStart,
1873
+ paddingInlineEnd,
1874
+ paddingBlock,
1875
+ paddingBlockStart,
1876
+ paddingBlockEnd,
1877
+ ...containerStyleOverrides
1878
+ } = style ?? {};
1398
1879
  const containerStyle = {
1399
1880
  display: "flex",
1400
1881
  justifyContent: "center",
@@ -1403,21 +1884,50 @@ function HpoVisualizer({
1403
1884
  position: "relative",
1404
1885
  width,
1405
1886
  height,
1406
- ...style
1887
+ ...containerStyleOverrides,
1888
+ // Apply overflow after style spread to ensure it takes precedence
1889
+ overflow: "clip"
1890
+ };
1891
+ const contentStyle = {
1892
+ display: "flex",
1893
+ justifyContent: "center",
1894
+ alignItems: "flex-end",
1895
+ width: "100%",
1896
+ height: "100%",
1897
+ boxSizing: "border-box",
1898
+ padding,
1899
+ paddingTop,
1900
+ paddingRight,
1901
+ paddingBottom,
1902
+ paddingLeft,
1903
+ paddingInline,
1904
+ paddingInlineStart,
1905
+ paddingInlineEnd,
1906
+ paddingBlock,
1907
+ paddingBlockStart,
1908
+ paddingBlockEnd
1909
+ };
1910
+ const svgStyle = {
1911
+ width: "100%",
1912
+ height: "100%",
1913
+ display: "block",
1914
+ cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
1915
+ overflow: "visible"
1407
1916
  };
1408
1917
  const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
1409
- const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
1410
- const translateX = (Number(width) - BODY_VIEWBOX.width * scale) / 2;
1918
+ const centerX = BODY_VIEWBOX.width / 2;
1919
+ const centerY = BODY_VIEWBOX.height / 2;
1920
+ const zoomTransform = `translate(${centerX} ${centerY}) scale(${zoom}) translate(${-centerX} ${-centerY})`;
1411
1921
  const renderOrgan = (organId, isVisible) => {
1412
1922
  const position = ORGAN_POSITIONS[organId];
1413
1923
  const config = organConfigMap.get(organId);
1414
1924
  if (config?.style?.visible === false) {
1415
1925
  return null;
1416
1926
  }
1417
- const x = translateX + position.x * scale;
1418
- const y = position.y * scale;
1419
- const width2 = position.width * scale;
1420
- const height2 = position.height * scale;
1927
+ const x = position.x;
1928
+ const y = position.y;
1929
+ const width2 = position.width;
1930
+ const height2 = position.height;
1421
1931
  return /* @__PURE__ */ jsxRuntime.jsx(
1422
1932
  OrganSvg,
1423
1933
  {
@@ -1440,44 +1950,83 @@ function HpoVisualizer({
1440
1950
  `${visualizerID}-${organId}`
1441
1951
  );
1442
1952
  };
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,
1459
- {
1460
- colorScale: colorPalette[DEFAULT_COLOR_NAME],
1461
- style: {
1462
- fill: "#fff"
1463
- }
1464
- }
1465
- )
1466
- ]
1467
- }
1468
- ),
1469
- FOREGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))
1470
- ] });
1953
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1954
+ "div",
1955
+ {
1956
+ className,
1957
+ style: containerStyle,
1958
+ onMouseEnter: () => setIsHovering(true),
1959
+ onMouseLeave: () => {
1960
+ setIsHovering(false);
1961
+ handleMouseUp();
1962
+ },
1963
+ onMouseDown: handleMouseDown,
1964
+ onMouseMove: handleMouseMove,
1965
+ onMouseUp: handleMouseUp,
1966
+ role: "application",
1967
+ tabIndex: 0,
1968
+ children: [
1969
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: contentStyle, children: /* @__PURE__ */ jsxRuntime.jsxs(
1970
+ "svg",
1971
+ {
1972
+ ref: containerRef,
1973
+ width: "100%",
1974
+ height: "100%",
1975
+ viewBox,
1976
+ preserveAspectRatio: "xMidYMid meet",
1977
+ style: svgStyle,
1978
+ "aria-label": "Human organ visualizer",
1979
+ children: [
1980
+ /* @__PURE__ */ jsxRuntime.jsx("title", { children: "Human organ visualizer" }),
1981
+ /* @__PURE__ */ jsxRuntime.jsx("g", { transform: `translate(${pan.x} ${pan.y})`, children: /* @__PURE__ */ jsxRuntime.jsx("g", { transform: zoomTransform, children: renderEntries.map((entry) => {
1982
+ if (entry.type === "body") {
1983
+ return /* @__PURE__ */ jsxRuntime.jsx(
1984
+ Body,
1985
+ {
1986
+ colorScale: colorPalette[DEFAULT_COLOR_NAME],
1987
+ style: {
1988
+ fill: "#fff"
1989
+ }
1990
+ },
1991
+ `${visualizerID}-body`
1992
+ );
1993
+ }
1994
+ return renderOrgan(entry.organId, visibleOrganIds.includes(entry.organId));
1995
+ }) }) })
1996
+ ]
1997
+ }
1998
+ ) }),
1999
+ /* @__PURE__ */ jsxRuntime.jsx(
2000
+ ZoomControls,
2001
+ {
2002
+ onZoomIn: zoomIn,
2003
+ onZoomOut: zoomOut,
2004
+ onReset: resetZoom,
2005
+ showResetButton: !isDefaultZoom,
2006
+ zoom,
2007
+ minZoom: MIN_ZOOM,
2008
+ maxZoom,
2009
+ isVisible: isHovering
2010
+ }
2011
+ )
2012
+ ]
2013
+ }
2014
+ );
1471
2015
  }
1472
2016
 
1473
2017
  exports.ANIMATION_DURATION_MS = ANIMATION_DURATION_MS;
1474
2018
  exports.DEFAULT_COLOR_PALETTE = DEFAULT_COLOR_PALETTE;
2019
+ exports.HPO_LABELS = HPO_LABELS;
2020
+ exports.HPO_LABEL_TO_ORGAN = HPO_LABEL_TO_ORGAN;
1475
2021
  exports.HpoVisualizer = HpoVisualizer;
1476
2022
  exports.ORGAN_COMPONENTS = ORGAN_COMPONENTS;
1477
2023
  exports.ORGAN_IDS = ORGAN_IDS;
1478
2024
  exports.ORGAN_NAMES_EN = ORGAN_NAMES_EN;
1479
2025
  exports.ORGAN_NAMES_KO = ORGAN_NAMES_KO;
2026
+ exports.ORGAN_TO_HPO_LABEL = ORGAN_TO_HPO_LABEL;
1480
2027
  exports.OrganSvg = OrganSvg;
2028
+ exports.createOrganOutlineSet = createOrganOutlineSet;
2029
+ exports.createUniformOrganColorSchemes = createUniformOrganColorSchemes;
1481
2030
  exports.useOrganInteraction = useOrganInteraction;
1482
2031
  //# sourceMappingURL=index.cjs.map
1483
2032
  //# sourceMappingURL=index.cjs.map