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