hpo-react-visualizer 0.0.2 → 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
@@ -192,7 +192,19 @@ var DEFAULT_COLOR_NAME = "blue";
192
192
  var DEFAULT_STROKE_COLOR = "#ff142d";
193
193
  var DEFAULT_STROKE_WIDTH = 0.5;
194
194
  var ANIMATION_DURATION_MS = 150;
195
- 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(", ");
196
208
  var BODY_VIEWBOX = {
197
209
  width: 122,
198
210
  height: 358
@@ -224,6 +236,20 @@ function createStrictColorPalette(palette) {
224
236
  return result;
225
237
  }, {});
226
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
+ };
227
253
  function Blood({ style, colorScale, isActive = false, className }) {
228
254
  const defaultColor = colorScale[200];
229
255
  const activeColor = colorScale[300];
@@ -1061,18 +1087,22 @@ function Neoplasm({ style, colorScale, isActive = false, className }) {
1061
1087
  const defaultColor = colorScale[100];
1062
1088
  const activeColor = colorScale[300];
1063
1089
  const fill = style?.fill ?? (isActive ? activeColor : defaultColor);
1064
- return /* @__PURE__ */ jsxRuntime.jsx("g", { className, "data-organ": "neoplasm", children: /* @__PURE__ */ jsxRuntime.jsx(
1065
- "path",
1066
- {
1067
- d: NEOPLASM_PATH,
1068
- fill,
1069
- stroke: style?.stroke,
1070
- strokeWidth: style?.strokeWidth,
1071
- style: { transition: TRANSITION_STYLE }
1072
- }
1073
- ) });
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
+ ] });
1074
1103
  }
1075
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";
1076
1106
  function Nervous({ style, colorScale, isActive = false, className }) {
1077
1107
  const defaultColor = colorScale[100];
1078
1108
  const activeColor = colorScale[200];
@@ -1237,25 +1267,21 @@ function OrganSvg({
1237
1267
  };
1238
1268
  return mergeStyles(userStyle, strokeStyle);
1239
1269
  }, [config?.style, showOutline]);
1240
- const svgStyle = {
1241
- position: "absolute",
1242
- left: x,
1243
- top: y,
1244
- transition: `${TRANSITION_STYLE}, visibility 0s`,
1245
- 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`
1246
1276
  };
1247
1277
  const filter = isActive ? "blur(1px)" : void 0;
1248
1278
  return /* @__PURE__ */ jsxRuntime.jsxs(
1249
- "svg",
1279
+ "g",
1250
1280
  {
1251
- width,
1252
- height,
1253
- viewBox,
1254
- style: svgStyle,
1281
+ transform,
1282
+ style: groupStyle,
1255
1283
  filter,
1256
1284
  "aria-label": organId,
1257
- overflow: "visible",
1258
- pointerEvents: "none",
1259
1285
  opacity: isVisible ? 1 : 0,
1260
1286
  visibility: isVisible ? "visible" : "hidden",
1261
1287
  children: [
@@ -1341,14 +1367,8 @@ function useOrganInteraction(options = {}) {
1341
1367
  setInternalSelected(newSelected);
1342
1368
  }
1343
1369
  onSelect?.(newSelected);
1344
- if (newSelected === null) {
1345
- if (!isHoverControlled) {
1346
- setInternalHovered(null);
1347
- }
1348
- onHover?.(null);
1349
- }
1350
1370
  },
1351
- [selectedOrgan, isSelectControlled, isHoverControlled, onSelect, onHover]
1371
+ [selectedOrgan, isSelectControlled, onSelect]
1352
1372
  );
1353
1373
  const state = react.useMemo(
1354
1374
  () => ({
@@ -1379,103 +1399,230 @@ function useOrganInteraction(options = {}) {
1379
1399
  isHovered
1380
1400
  };
1381
1401
  }
1402
+ var ZOOM_ANIMATION_MS = 180;
1403
+ var easeOutCubic = (t) => 1 - (1 - t) ** 3;
1382
1404
  function useZoom(options = {}) {
1383
- const { minZoom = 1, maxZoom = 5, zoomStep = 0.5 } = options;
1405
+ const { minZoom = 1, maxZoom = 5, zoomStep = 0.5, wheelZoom = true, viewBox } = options;
1384
1406
  const [zoom, setZoom] = react.useState(1);
1385
1407
  const [pan, setPan] = react.useState({ x: 0, y: 0 });
1386
1408
  const [isDragging, setIsDragging] = react.useState(false);
1387
1409
  const dragStartRef = react.useRef({ x: 0, y: 0 });
1388
1410
  const panStartRef = react.useRef({ x: 0, y: 0 });
1389
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);
1390
1415
  const clampZoom = react.useCallback(
1391
1416
  (value) => Math.max(minZoom, Math.min(maxZoom, value)),
1392
1417
  [minZoom, maxZoom]
1393
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
+ );
1394
1475
  const zoomIn = react.useCallback(() => {
1395
- setZoom((prev) => clampZoom(prev + zoomStep));
1396
- }, [clampZoom, zoomStep]);
1476
+ const nextZoom = clampZoom(zoomRef.current + zoomStep);
1477
+ animateZoom(nextZoom);
1478
+ }, [animateZoom, clampZoom, zoomStep]);
1397
1479
  const zoomOut = react.useCallback(() => {
1398
- setZoom((prev) => clampZoom(prev - zoomStep));
1399
- }, [clampZoom, zoomStep]);
1480
+ const nextZoom = clampZoom(zoomRef.current - zoomStep);
1481
+ animateZoom(nextZoom);
1482
+ }, [animateZoom, clampZoom, zoomStep]);
1400
1483
  const resetZoom = react.useCallback(() => {
1401
- setZoom(1);
1402
- setPan({ x: 0, y: 0 });
1403
- }, []);
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]);
1404
1560
  react.useEffect(() => {
1405
1561
  const container = containerRef.current;
1406
- if (!container) return;
1562
+ if (!container || !wheelZoom) return;
1407
1563
  const wheelZoomStep = 0.1;
1408
1564
  const handleWheel = (e) => {
1409
1565
  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;
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;
1413
1573
  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
- });
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);
1434
1587
  };
1435
1588
  container.addEventListener("wheel", handleWheel, { passive: false });
1436
1589
  return () => {
1437
1590
  container.removeEventListener("wheel", handleWheel);
1438
1591
  };
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
- }, []);
1592
+ }, [clampZoom, wheelZoom, getViewBoxMetrics, getPointerPosition, clampPan, stopAnimation]);
1454
1593
  react.useEffect(() => {
1455
- setPan((currentPan) => clampPan(currentPan, zoom));
1456
- }, [zoom, clampPan]);
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]);
1457
1599
  const handleMouseDown = react.useCallback(
1458
1600
  (e) => {
1459
1601
  if (e.button !== 0 || zoom <= 1) return;
1602
+ stopAnimation();
1460
1603
  setIsDragging(true);
1461
1604
  dragStartRef.current = { x: e.clientX, y: e.clientY };
1462
1605
  panStartRef.current = { ...pan };
1463
1606
  e.preventDefault();
1464
1607
  },
1465
- [pan, zoom]
1608
+ [pan, zoom, stopAnimation]
1466
1609
  );
1467
1610
  const handleMouseMove = react.useCallback(
1468
1611
  (e) => {
1469
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;
1470
1617
  const dx = e.clientX - dragStartRef.current.x;
1471
1618
  const dy = e.clientY - dragStartRef.current.y;
1472
1619
  const newPan = {
1473
- x: panStartRef.current.x + dx,
1474
- y: panStartRef.current.y + dy
1620
+ x: panStartRef.current.x + dx / scale,
1621
+ y: panStartRef.current.y + dy / scale
1475
1622
  };
1476
- setPan(clampPan(newPan, zoom));
1623
+ setPan(clampPan(newPan, zoom, viewBoxWidth, viewBoxHeight));
1477
1624
  },
1478
- [isDragging, zoom, clampPan]
1625
+ [isDragging, zoom, clampPan, getViewBoxMetrics]
1479
1626
  );
1480
1627
  const handleMouseUp = react.useCallback(() => {
1481
1628
  setIsDragging(false);
@@ -1643,11 +1790,12 @@ function HpoVisualizer({
1643
1790
  onHover,
1644
1791
  onSelect,
1645
1792
  colorPalette: inputColorPalette,
1646
- width = BODY_VIEWBOX.width,
1647
- height = BODY_VIEWBOX.height,
1793
+ width = "100%",
1794
+ height = "100%",
1648
1795
  className,
1649
1796
  style,
1650
- maxZoom = 5
1797
+ maxZoom = 5,
1798
+ wheelZoom = true
1651
1799
  }) {
1652
1800
  const visualizerID = react.useId();
1653
1801
  const [isHovering, setIsHovering] = react.useState(false);
@@ -1663,7 +1811,7 @@ function HpoVisualizer({
1663
1811
  isDragging,
1664
1812
  isDefaultZoom,
1665
1813
  containerRef
1666
- } = useZoom({ minZoom: MIN_ZOOM, maxZoom });
1814
+ } = useZoom({ minZoom: MIN_ZOOM, maxZoom, wheelZoom, viewBox: BODY_VIEWBOX });
1667
1815
  const colorPalette = react.useMemo(
1668
1816
  () => createStrictColorPalette(inputColorPalette),
1669
1817
  [inputColorPalette]
@@ -1686,12 +1834,48 @@ function HpoVisualizer({
1686
1834
  }
1687
1835
  return map;
1688
1836
  }, [organs]);
1689
- const { handlers, isHovered, isSelected } = useOrganInteraction({
1837
+ const { handlers, isHovered, isSelected, state } = useOrganInteraction({
1690
1838
  hoveredOrgan: controlledHovered,
1691
1839
  selectedOrgan: controlledSelected,
1692
1840
  onHover,
1693
1841
  onSelect
1694
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 ?? {};
1695
1879
  const containerStyle = {
1696
1880
  display: "flex",
1697
1881
  justifyContent: "center",
@@ -1700,33 +1884,50 @@ function HpoVisualizer({
1700
1884
  position: "relative",
1701
1885
  width,
1702
1886
  height,
1703
- ...style,
1887
+ ...containerStyleOverrides,
1704
1888
  // Apply overflow after style spread to ensure it takes precedence
1705
- // Use 'clip' instead of 'hidden' to respect padding
1706
1889
  overflow: "clip"
1707
1890
  };
1708
1891
  const contentStyle = {
1709
- position: "relative",
1892
+ display: "flex",
1893
+ justifyContent: "center",
1894
+ alignItems: "flex-end",
1710
1895
  width: "100%",
1711
1896
  height: "100%",
1712
- transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
1713
- transformOrigin: "center center",
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",
1714
1914
  cursor: isDragging ? "grabbing" : zoom > 1 ? "grab" : "default",
1715
- transition: isDragging ? "none" : "transform 0.1s ease-out"
1915
+ overflow: "visible"
1716
1916
  };
1717
1917
  const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
1718
- const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
1719
- 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})`;
1720
1921
  const renderOrgan = (organId, isVisible) => {
1721
1922
  const position = ORGAN_POSITIONS[organId];
1722
1923
  const config = organConfigMap.get(organId);
1723
1924
  if (config?.style?.visible === false) {
1724
1925
  return null;
1725
1926
  }
1726
- const x = translateX + position.x * scale;
1727
- const y = position.y * scale;
1728
- const width2 = position.width * scale;
1729
- 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;
1730
1931
  return /* @__PURE__ */ jsxRuntime.jsx(
1731
1932
  OrganSvg,
1732
1933
  {
@@ -1752,7 +1953,6 @@ function HpoVisualizer({
1752
1953
  return /* @__PURE__ */ jsxRuntime.jsxs(
1753
1954
  "div",
1754
1955
  {
1755
- ref: containerRef,
1756
1956
  className,
1757
1957
  style: containerStyle,
1758
1958
  onMouseEnter: () => setIsHovering(true),
@@ -1766,38 +1966,36 @@ function HpoVisualizer({
1766
1966
  role: "application",
1767
1967
  tabIndex: 0,
1768
1968
  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",
1775
- {
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
- ]
1795
- }
1796
- ),
1797
- FOREGROUND_ORGANS.map(
1798
- (organId) => renderOrgan(organId, visibleOrganIds.includes(organId))
1799
- )
1800
- ] }),
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
+ ) }),
1801
1999
  /* @__PURE__ */ jsxRuntime.jsx(
1802
2000
  ZoomControls,
1803
2001
  {
@@ -1827,6 +2025,8 @@ exports.ORGAN_NAMES_EN = ORGAN_NAMES_EN;
1827
2025
  exports.ORGAN_NAMES_KO = ORGAN_NAMES_KO;
1828
2026
  exports.ORGAN_TO_HPO_LABEL = ORGAN_TO_HPO_LABEL;
1829
2027
  exports.OrganSvg = OrganSvg;
2028
+ exports.createOrganOutlineSet = createOrganOutlineSet;
2029
+ exports.createUniformOrganColorSchemes = createUniformOrganColorSchemes;
1830
2030
  exports.useOrganInteraction = useOrganInteraction;
1831
2031
  //# sourceMappingURL=index.cjs.map
1832
2032
  //# sourceMappingURL=index.cjs.map