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 +335 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -6
- package/dist/index.d.ts +13 -6
- package/dist/index.js +334 -136
- package/dist/index.js.map +1 -1
- package/package.json +15 -15
- package/src/HpoVisualizer.tsx +103 -37
- package/src/OrganSvg.tsx +13 -17
- package/src/__tests__/hpoVisualizerSizing.test.tsx +22 -0
- package/src/__tests__/organControlState.test.ts +26 -0
- package/src/__tests__/renderOrder.test.tsx +24 -37
- package/src/__tests__/transitionStyle.test.ts +11 -0
- package/src/__tests__/useOrganInteraction.test.ts +31 -0
- package/src/__tests__/useZoom.test.ts +34 -0
- package/src/constants.ts +14 -1
- package/src/index.ts +1 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/organControlState.ts +21 -0
- package/src/svg/Neoplasm.tsx +3 -0
- package/src/types.ts +3 -0
- package/src/useOrganInteraction.ts +2 -9
- package/src/useZoom.ts +224 -73
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
|
|
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.
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
"
|
|
1279
|
+
"g",
|
|
1250
1280
|
{
|
|
1251
|
-
|
|
1252
|
-
|
|
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,
|
|
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
|
-
|
|
1396
|
-
|
|
1476
|
+
const nextZoom = clampZoom(zoomRef.current + zoomStep);
|
|
1477
|
+
animateZoom(nextZoom);
|
|
1478
|
+
}, [animateZoom, clampZoom, zoomStep]);
|
|
1397
1479
|
const zoomOut = react.useCallback(() => {
|
|
1398
|
-
|
|
1399
|
-
|
|
1480
|
+
const nextZoom = clampZoom(zoomRef.current - zoomStep);
|
|
1481
|
+
animateZoom(nextZoom);
|
|
1482
|
+
}, [animateZoom, clampZoom, zoomStep]);
|
|
1400
1483
|
const resetZoom = react.useCallback(() => {
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1411
|
-
const
|
|
1412
|
-
const
|
|
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
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
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 =
|
|
1647
|
-
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
|
-
...
|
|
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
|
-
|
|
1892
|
+
display: "flex",
|
|
1893
|
+
justifyContent: "center",
|
|
1894
|
+
alignItems: "flex-end",
|
|
1710
1895
|
width: "100%",
|
|
1711
1896
|
height: "100%",
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
1915
|
+
overflow: "visible"
|
|
1716
1916
|
};
|
|
1717
1917
|
const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
|
|
1718
|
-
const
|
|
1719
|
-
const
|
|
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 =
|
|
1727
|
-
const y = position.y
|
|
1728
|
-
const width2 = position.width
|
|
1729
|
-
const height2 = position.height
|
|
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.
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
"
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|