@fusefactory/fuse-three-forcegraph 1.0.2 → 1.0.4

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.d.mts CHANGED
@@ -220,6 +220,7 @@ interface GraphPreset {
220
220
  };
221
221
  links?: {
222
222
  opacity?: number;
223
+ noiseStrength?: number;
223
224
  };
224
225
  }
225
226
  //#endregion
@@ -802,6 +803,10 @@ declare class LinksRenderer {
802
803
  * Highlight links connected to a specific node
803
804
  */
804
805
  highlightConnectedLinks(nodeId: string, step?: number): void;
806
+ /**
807
+ * Highlight links connected to any of the given nodes
808
+ */
809
+ highlightConnectedToNodes(nodeIds: Set<string>, step?: number): void;
805
810
  /**
806
811
  * Clear all highlights (return to defaultAlpha)
807
812
  */
@@ -1013,20 +1018,15 @@ declare class TooltipManager {
1013
1018
  * Hide all tooltips
1014
1019
  */
1015
1020
  hideAll(): void;
1016
- /**
1017
- * Generate preview content (subset of fields)
1018
- */
1019
- private generatePreviewContent;
1020
- /**
1021
- * Generate full tooltip content (all fields)
1022
- */
1023
- private generateFullContent;
1024
- private escapeHtml;
1025
1021
  showMainTooltip(content: string, x: number, y: number): void;
1026
1022
  hideMainTooltip(): void;
1027
1023
  showChainTooltip(nodeId: string, content: string, x: number, y: number): void;
1028
1024
  hideChainTooltips(): void;
1029
1025
  updateMainTooltipPos(x: number, y: number): void;
1026
+ /**
1027
+ * Set main tooltip visibility (doesn't affect open state or content)
1028
+ */
1029
+ setMainTooltipVisibility(visible: boolean): void;
1030
1030
  dispose(): void;
1031
1031
  }
1032
1032
  //#endregion
@@ -1225,6 +1225,7 @@ declare class Engine {
1225
1225
  private isRunning;
1226
1226
  private boundResizeHandler;
1227
1227
  private groupOrder?;
1228
+ private smoothedTooltipPos;
1228
1229
  constructor(canvas: HTMLCanvasElement, options?: EngineOptions);
1229
1230
  /**
1230
1231
  * Handle window resize event
@@ -1239,6 +1240,17 @@ declare class Engine {
1239
1240
  * This allows changing visibility/behavior without resetting the simulation
1240
1241
  */
1241
1242
  updateNodeStates(callback: (node: GraphNode) => NodeState): void;
1243
+ /**
1244
+ * Set target positions for elastic force (tree layout, etc.)
1245
+ * Writes positions into the "original positions" GPU buffer that ElasticPass reads from,
1246
+ * without touching current positions or node states.
1247
+ * Must be called AFTER applyPreset() since updateNodeStates() overwrites original positions.
1248
+ */
1249
+ setTargetPositions(targets: Map<string, {
1250
+ x: number;
1251
+ y: number;
1252
+ z: number;
1253
+ }>): void;
1242
1254
  /**
1243
1255
  * Reheat the simulation
1244
1256
  */
@@ -1289,13 +1301,16 @@ declare class Engine {
1289
1301
  getNodePosition(nodeId: string): THREE.Vector3 | null;
1290
1302
  /**
1291
1303
  * Get node screen position from world position
1304
+ * Returns position and visibility info (whether node is on screen and in front of camera)
1292
1305
  */
1293
1306
  getNodeScreenPosition(nodeId: string): {
1294
1307
  x: number;
1295
1308
  y: number;
1309
+ visible: boolean;
1296
1310
  } | null;
1297
1311
  /**
1298
1312
  * Update sticky tooltip position to follow node during camera movement
1313
+ * Uses lerp smoothing to filter out micro-jitter from force simulation
1299
1314
  */
1300
1315
  private updateStickyTooltipPosition;
1301
1316
  /**
package/dist/index.mjs CHANGED
@@ -449,6 +449,18 @@ var Tooltip = class {
449
449
  if (this.positionCallback) this.positionCallback(x, y);
450
450
  }
451
451
  }
452
+ /**
453
+ * Show tooltip (visibility only, doesn't affect open state)
454
+ */
455
+ show() {
456
+ if (this.isVisible) this.element.style.visibility = "visible";
457
+ }
458
+ /**
459
+ * Hide tooltip (visibility only, doesn't close/cleanup)
460
+ */
461
+ hide() {
462
+ this.element.style.visibility = "hidden";
463
+ }
452
464
  getElement() {
453
465
  return this.element;
454
466
  }
@@ -466,12 +478,9 @@ var TooltipManager = class {
466
478
  this.canvas = canvas;
467
479
  this.chainTooltips = /* @__PURE__ */ new Map();
468
480
  this.closeButton = null;
469
- this.config = {
470
- useDefaultStyles: true,
471
- ...config
472
- };
473
- this.mainTooltip = new Tooltip("main", container, this.config.useDefaultStyles);
474
- this.previewTooltip = new Tooltip("preview", container, this.config.useDefaultStyles);
481
+ this.config = { ...config };
482
+ this.mainTooltip = new Tooltip("main", container);
483
+ this.previewTooltip = new Tooltip("preview", container);
475
484
  }
476
485
  /**
477
486
  * Show grab cursor when hovering over a node
@@ -594,45 +603,16 @@ var TooltipManager = class {
594
603
  this.hideFull();
595
604
  this.hideChainTooltips();
596
605
  }
597
- /**
598
- * Generate preview content (subset of fields)
599
- */
600
- generatePreviewContent(node) {
601
- return `
602
- <div class="tooltip-preview" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
603
- <h3 style="margin: 0 0 8px 0; font-size: 16px; font-weight: 600;">${this.escapeHtml(node.title || node.id)}</h3>
604
- <p style="margin: 0; font-size: 13px; opacity: 0.8;">${this.escapeHtml(node.group || "unknown")}</p>
605
- </div>
606
- `;
607
- }
608
- /**
609
- * Generate full tooltip content (all fields)
610
- */
611
- generateFullContent(node) {
612
- return `
613
- <div class="tooltip-full" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding-right: 24px;">
614
- <h2 style="margin: 0 0 12px 0; font-size: 18px; font-weight: 600;">${this.escapeHtml(node.title || node.id)}</h2>
615
- ${node.thumbnailUrl ? `<img src="${this.escapeHtml(node.thumbnailUrl)}" alt="thumbnail" style="max-width: 100%; height: auto; border-radius: 4px; margin: 0 0 12px 0;" />` : ""}
616
- <p style="margin: 0 0 8px 0; font-size: 13px;"><strong>Group:</strong> ${this.escapeHtml(node.group || "unknown")}</p>
617
- ${node.description ? `<p style="margin: 0 0 8px 0; font-size: 13px; line-height: 1.5;">${this.escapeHtml(node.description)}</p>` : ""}
618
- <p style="margin: 0; font-size: 12px; opacity: 0.6;"><strong>ID:</strong> ${this.escapeHtml(node.id)}</p>
619
- </div>
620
- `;
621
- }
622
- escapeHtml(text) {
623
- const div = document.createElement("div");
624
- div.textContent = text;
625
- return div.innerHTML;
626
- }
627
606
  showMainTooltip(content, x, y) {
628
607
  this.mainTooltip.open(content);
629
608
  this.mainTooltip.updatePos(x, y);
609
+ console.log("Showing main tooltip");
630
610
  }
631
611
  hideMainTooltip() {
632
612
  this.mainTooltip.close();
633
613
  }
634
614
  showChainTooltip(nodeId, content, x, y) {
635
- if (!this.chainTooltips.has(nodeId)) this.chainTooltips.set(nodeId, new Tooltip("label", this.container, this.config.useDefaultStyles));
615
+ if (!this.chainTooltips.has(nodeId)) this.chainTooltips.set(nodeId, new Tooltip("label", this.container));
636
616
  const tooltip = this.chainTooltips.get(nodeId);
637
617
  tooltip.open(content);
638
618
  tooltip.updatePos(x, y);
@@ -643,6 +623,13 @@ var TooltipManager = class {
643
623
  updateMainTooltipPos(x, y) {
644
624
  this.mainTooltip.updatePos(x, y);
645
625
  }
626
+ /**
627
+ * Set main tooltip visibility (doesn't affect open state or content)
628
+ */
629
+ setMainTooltipVisibility(visible) {
630
+ if (visible) this.mainTooltip.show();
631
+ else this.mainTooltip.hide();
632
+ }
646
633
  dispose() {
647
634
  this.removeCloseButton();
648
635
  this.mainTooltip.destroy();
@@ -699,7 +686,7 @@ var InteractionManager = class {
699
686
  const linkRenderer = this.graphScene.getLinkRenderer();
700
687
  const nodeRenderer = this.graphScene.getNodeRenderer();
701
688
  if (this.searchHighlightIds.length > 0) {
702
- linkRenderer?.clearHighlights(.05);
689
+ linkRenderer?.highlightConnectedToNodes(new Set(this.searchHighlightIds), .05);
703
690
  nodeRenderer?.highlight(this.searchHighlightIds);
704
691
  } else {
705
692
  linkRenderer?.clearHighlights(.05);
@@ -741,7 +728,7 @@ var InteractionManager = class {
741
728
  const linkRenderer = this.graphScene.getLinkRenderer();
742
729
  const nodeRenderer = this.graphScene.getNodeRenderer();
743
730
  if (this.searchHighlightIds.length > 0) {
744
- linkRenderer?.clearHighlights(.05);
731
+ linkRenderer?.highlightConnectedToNodes(new Set(this.searchHighlightIds), .05);
745
732
  nodeRenderer?.highlight(this.searchHighlightIds);
746
733
  } else {
747
734
  linkRenderer?.clearHighlights(.05);
@@ -789,7 +776,7 @@ var InteractionManager = class {
789
776
  nodeRenderer?.highlight(connectedIds);
790
777
  }
791
778
  } else if (this.searchHighlightIds.length > 0) {
792
- linkRenderer?.clearHighlights(.05);
779
+ linkRenderer?.highlightConnectedToNodes(new Set(this.searchHighlightIds), .05);
793
780
  nodeRenderer?.highlight(this.searchHighlightIds);
794
781
  } else {
795
782
  linkRenderer?.clearHighlights(.05);
@@ -995,11 +982,11 @@ var CameraController = class {
995
982
 
996
983
  //#endregion
997
984
  //#region assets/glsl/lines/lines.frag
998
- var lines_default$1 = "precision mediump float;\n\nvarying vec3 vColor;\r\nflat varying float vAlphaIndex;\n\nuniform sampler2D uAlphaTexture;\r\nuniform vec2 uAlphaTextureSize; \n\nvec2 alphaUvFromIndex(float idx) {\r\n float width = uAlphaTextureSize.x;\r\n float texX = mod(idx, width);\r\n float texY = floor(idx / width);\r\n return (vec2(texX + 0.5, texY + 0.5) / uAlphaTextureSize);\r\n}\n\nvoid main() {\r\n vec2 uv = alphaUvFromIndex(vAlphaIndex);\r\n float a = texture2D(uAlphaTexture, uv).r;\n\n gl_FragColor = vec4(vColor, a);\r\n #include <colorspace_fragment>\r\n}";
985
+ var lines_default$1 = "precision mediump float;\n\nvarying vec3 vColor;\r\nflat varying float vAlphaIndex;\r\nflat varying float vRank;\n\nuniform sampler2D uAlphaTexture;\r\nuniform vec2 uAlphaTextureSize; \n\nvec2 alphaUvFromIndex(float idx) {\r\n float width = uAlphaTextureSize.x;\r\n float texX = mod(idx, width);\r\n float texY = floor(idx / width);\r\n return (vec2(texX + 0.5, texY + 0.5) / uAlphaTextureSize);\r\n}\n\nvoid main() {\r\n vec2 uv = alphaUvFromIndex(vAlphaIndex);\r\n float a = texture2D(uAlphaTexture, uv).r;\n\n gl_FragColor = vec4(vColor, a * vRank);\r\n #include <colorspace_fragment>\r\n}";
999
986
 
1000
987
  //#endregion
1001
988
  //#region assets/glsl/lines/lines.vert
1002
- var lines_default = "attribute float t;\n\nattribute vec2 instanceLinkA;\r\nattribute vec2 instanceLinkB;\r\nattribute vec3 instanceColorA;\r\nattribute vec3 instanceColorB;\r\nattribute float instanceAlphaIndex;\n\nuniform sampler2D uPositionsTexture;\r\nuniform vec2 uPositionsTextureSize;\r\nuniform float uNoiseStrength;\r\nuniform float uTime;\n\nvarying vec3 vColor;\r\nflat varying float vAlphaIndex;\n\nvec3 organicNoise(vec3 p, float seed) {\r\n float frequency = 1.5;\r\n float branchiness = 5.;\r\n \r\n \n vec3 n1 = sin(p * frequency + seed) * 0.5;\r\n vec3 n2 = sin(p * frequency * 2.3 + seed * 1.5) * 0.25 * (1.0 + branchiness);\r\n vec3 n3 = sin(p * frequency * 4.7 + seed * 2.3) * 0.125 * (1.0 + branchiness);\r\n \r\n return n1 + n2 + n3;\r\n}\n\nfloat smoothCurve(float t) {\r\n return t * t * (3.0 - 2.0 * t);\r\n}\n\nvoid main() {\r\n \n vColor = mix(instanceColorA, instanceColorB, t);\r\n vAlphaIndex = instanceAlphaIndex;\n\n \n vec2 uvA = (instanceLinkA + vec2(0.5)) / uPositionsTextureSize;\r\n vec2 uvB = (instanceLinkB + vec2(0.5)) / uPositionsTextureSize;\n\n vec4 posA = texture2D(uPositionsTexture, uvA);\r\n vec4 posB = texture2D(uPositionsTexture, uvB);\r\n if (posA.w < 0.5 || posB.w < 0.5) {\r\n \n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); \r\n return;\r\n }\r\n vec3 linear = mix(posA.xyz, posB.xyz, t);\r\n \r\n \n vec3 dir = normalize(posB.xyz - posA.xyz);\r\n float dist = length(posB.xyz - posA.xyz);\r\n vec3 up = abs(dir.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);\r\n vec3 perp = normalize(cross(dir, up));\r\n vec3 upVec = normalize(cross(perp, dir));\n\n \r\n \n float sinPhase = instanceAlphaIndex * 0.1 + uTime * 0.3;\r\n float sinWave = sin(t * 3.14159265 * 2.0 + sinPhase);\r\n \r\n \n float tSmooth = smoothCurve(t);\r\n float curve = sin(t * 3.14159265) * tSmooth;\r\n \r\n \n float curvature = 0.1;\r\n curve = pow(curve, 1.0 - curvature * 0.5);\r\n \r\n float intensity = curve * uNoiseStrength;\n\n vec3 pos = linear;\n\n if (intensity > 0.001) {\r\n \n vec3 samplePos = linear * 0.5 + vec3(instanceAlphaIndex, 0.0, instanceAlphaIndex * 0.7);\r\n vec3 noise = organicNoise(samplePos, instanceAlphaIndex);\r\n \r\n \n float sinModulation = 0.5 + 0.5 * sinWave;\r\n pos += perp * noise.x * intensity * sinModulation;\r\n pos += upVec * noise.y * intensity * 0.7;\r\n pos += dir * noise.z * intensity * 0.3;\r\n }\n\n vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);\r\n gl_Position = projectionMatrix * mvPosition;\r\n}";
989
+ var lines_default = "attribute float t;\n\nattribute vec2 instanceLinkA;\r\nattribute vec2 instanceLinkB;\r\nattribute vec3 instanceColorA;\r\nattribute vec3 instanceColorB;\r\nattribute float instanceAlphaIndex;\r\nattribute float instanceRank;\n\nuniform sampler2D uPositionsTexture;\r\nuniform vec2 uPositionsTextureSize;\r\nuniform float uNoiseStrength;\r\nuniform float uTime;\n\nvarying vec3 vColor;\r\nflat varying float vAlphaIndex;\r\nflat varying float vRank;\n\nvec3 organicNoise(vec3 p, float seed) {\r\n float frequency = 1.5;\r\n float branchiness = 5.;\r\n \r\n \n vec3 n1 = sin(p * frequency + seed) * 0.5;\r\n vec3 n2 = sin(p * frequency * 2.3 + seed * 1.5) * 0.25 * (1.0 + branchiness);\r\n vec3 n3 = sin(p * frequency * 4.7 + seed * 2.3) * 0.125 * (1.0 + branchiness);\r\n \r\n return n1 + n2 + n3;\r\n}\n\nfloat smoothCurve(float t) {\r\n return t * t * (3.0 - 2.0 * t);\r\n}\n\nvoid main() {\r\n \n vColor = mix(instanceColorA, instanceColorB, t);\r\n vAlphaIndex = instanceAlphaIndex;\r\n vRank = instanceRank;\n\n \n vec2 uvA = (instanceLinkA + vec2(0.5)) / uPositionsTextureSize;\r\n vec2 uvB = (instanceLinkB + vec2(0.5)) / uPositionsTextureSize;\n\n vec4 posA = texture2D(uPositionsTexture, uvA);\r\n vec4 posB = texture2D(uPositionsTexture, uvB);\r\n if (posA.w < 0.5 || posB.w < 0.5) {\r\n \n gl_Position = vec4(2.0, 2.0, 2.0, 1.0); \r\n return;\r\n }\r\n vec3 linear = mix(posA.xyz, posB.xyz, t);\r\n \r\n \n vec3 dir = normalize(posB.xyz - posA.xyz);\r\n float dist = length(posB.xyz - posA.xyz);\r\n vec3 up = abs(dir.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);\r\n vec3 perp = normalize(cross(dir, up));\r\n vec3 upVec = normalize(cross(perp, dir));\n\n \r\n \n float sinPhase = instanceAlphaIndex * 0.1 + uTime * 0.3;\r\n float sinWave = sin(t * 3.14159265 * 2.0 + sinPhase);\r\n \r\n \n float tSmooth = smoothCurve(t);\r\n float curve = sin(t * 3.14159265) * tSmooth;\r\n \r\n \n float curvature = 0.1;\r\n curve = pow(curve, 1.0 - curvature * 0.5);\r\n \r\n float intensity = curve * uNoiseStrength;\n\n vec3 pos = linear;\n\n if (intensity > 0.001) {\r\n \n vec3 samplePos = linear * 0.5 + vec3(instanceAlphaIndex, 0.0, instanceAlphaIndex * 0.7);\r\n vec3 noise = organicNoise(samplePos, instanceAlphaIndex);\r\n \r\n \n float sinModulation = 0.5 + 0.5 * sinWave;\r\n pos += perp * noise.x * intensity * sinModulation;\r\n pos += upVec * noise.y * intensity * 0.7;\r\n pos += dir * noise.z * intensity * 0.3;\r\n }\n\n vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);\r\n gl_Position = projectionMatrix * mvPosition;\r\n}";
1003
990
 
1004
991
  //#endregion
1005
992
  //#region core/StyleRegistry.ts
@@ -1450,7 +1437,7 @@ var LinksRenderer = class {
1450
1437
  this.linkIndexMap = /* @__PURE__ */ new Map();
1451
1438
  this.interpolationSteps = 24;
1452
1439
  this.params = {
1453
- noiseStrength: .2,
1440
+ noiseStrength: .1,
1454
1441
  defaultAlpha: .02,
1455
1442
  highlightAlpha: .8,
1456
1443
  dimmedAlpha: .02,
@@ -1545,6 +1532,26 @@ var LinksRenderer = class {
1545
1532
  this.linkOpacity.setTargets(targets);
1546
1533
  }
1547
1534
  /**
1535
+ * Highlight links connected to any of the given nodes
1536
+ */
1537
+ highlightConnectedToNodes(nodeIds, step = .01) {
1538
+ if (!this.lines || !this.linkOpacity) return;
1539
+ const linkCount = this.lines.geometry instanceof THREE.InstancedBufferGeometry ? this.lines.geometry.instanceCount : 0;
1540
+ if (linkCount === 0 || linkCount === void 0) return;
1541
+ const targets = new Array(linkCount).fill(this.params.dimmedAlpha);
1542
+ let hasConnections = false;
1543
+ for (const [linkId, instanceIndex] of this.linkIndexMap.entries()) {
1544
+ const [sourceId, targetId] = linkId.split("-");
1545
+ if (nodeIds.has(sourceId) || nodeIds.has(targetId)) {
1546
+ targets[instanceIndex] = this.params.defaultAlpha;
1547
+ hasConnections = true;
1548
+ }
1549
+ }
1550
+ if (!hasConnections) return;
1551
+ this.linkOpacity.setStep(step);
1552
+ this.linkOpacity.setTargets(targets);
1553
+ }
1554
+ /**
1548
1555
  * Clear all highlights (return to defaultAlpha)
1549
1556
  */
1550
1557
  clearHighlights(step = .1) {
@@ -1636,7 +1643,13 @@ var LinksRenderer = class {
1636
1643
  /**
1637
1644
  * Create link geometry with interpolated segments
1638
1645
  */
1639
- createLinkGeometry(links, nodes, nodeTextureSize) {
1646
+ createLinkGeometry(links, nodes, nodeTextureSize, groupOrder = [
1647
+ "root",
1648
+ "series",
1649
+ "artwork",
1650
+ "exhibition",
1651
+ "media"
1652
+ ]) {
1640
1653
  const linkIndexMap = /* @__PURE__ */ new Map();
1641
1654
  const nodeMap = /* @__PURE__ */ new Map();
1642
1655
  nodes.forEach((node) => nodeMap.set(node.id, node));
@@ -1664,6 +1677,11 @@ var LinksRenderer = class {
1664
1677
  const instanceColorA = new Float32Array(links.length * 3);
1665
1678
  const instanceColorB = new Float32Array(links.length * 3);
1666
1679
  const instanceAlphaIndex = new Float32Array(links.length);
1680
+ const instanceRank = new Float32Array(links.length);
1681
+ const categoryRankMap = /* @__PURE__ */ new Map();
1682
+ groupOrder.forEach((g, i) => categoryRankMap.set(g, i));
1683
+ let nextRank = groupOrder.length;
1684
+ const maxRank = Math.max(groupOrder.length - 1, 1);
1667
1685
  links.forEach((link, i) => {
1668
1686
  const sourceId = link.source && typeof link.source === "object" ? link.source.id : link.source;
1669
1687
  const targetId = link.target && typeof link.target === "object" ? link.target.id : link.target;
@@ -1698,6 +1716,9 @@ var LinksRenderer = class {
1698
1716
  instanceColorB[i * 3 + 1] = tgtColor.g;
1699
1717
  instanceColorB[i * 3 + 2] = tgtColor.b;
1700
1718
  instanceAlphaIndex[i] = i;
1719
+ const targetCategory = targetNode?.category ?? "";
1720
+ if (targetCategory && !categoryRankMap.has(targetCategory)) categoryRankMap.set(targetCategory, nextRank++);
1721
+ instanceRank[i] = 1 - .75 * ((categoryRankMap.get(targetCategory) ?? 0) / maxRank);
1701
1722
  const linkId = `${sourceId}-${targetId}`;
1702
1723
  linkIndexMap.set(linkId, i);
1703
1724
  });
@@ -1706,6 +1727,7 @@ var LinksRenderer = class {
1706
1727
  geometry.setAttribute("instanceColorA", new THREE.InstancedBufferAttribute(instanceColorA, 3));
1707
1728
  geometry.setAttribute("instanceColorB", new THREE.InstancedBufferAttribute(instanceColorB, 3));
1708
1729
  geometry.setAttribute("instanceAlphaIndex", new THREE.InstancedBufferAttribute(instanceAlphaIndex, 1));
1730
+ geometry.setAttribute("instanceRank", new THREE.InstancedBufferAttribute(instanceRank, 1));
1709
1731
  return {
1710
1732
  geometry,
1711
1733
  linkIndexMap
@@ -1780,10 +1802,8 @@ var LinksRenderer = class {
1780
1802
  const category = nodeCategories[tIdx] || "";
1781
1803
  if (category && !categoryRankMap.has(category)) categoryRankMap.set(category, nextRank++);
1782
1804
  const rank = categoryRankMap.get(category) ?? 0;
1783
- const strength = 1 / (rank + 1);
1784
- const distance = .3 ** rank;
1785
- linkPropertiesData[di] = strength;
1786
- linkPropertiesData[di + 1] = distance;
1805
+ 1 / (rank + 1);
1806
+ .3 ** rank;
1787
1807
  }
1788
1808
  nodeCursor[tIdx] = offset + 1;
1789
1809
  }
@@ -2235,7 +2255,8 @@ var GraphScene = class {
2235
2255
  canvas,
2236
2256
  antialias: true,
2237
2257
  alpha: true,
2238
- depth: true
2258
+ depth: true,
2259
+ logarithmicDepthBuffer: true
2239
2260
  });
2240
2261
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
2241
2262
  this.renderer.setSize(window.innerWidth, window.innerHeight);
@@ -3137,7 +3158,7 @@ var ForceSimulation = class {
3137
3158
  }
3138
3159
  updateDrag(targetWorldPos) {
3139
3160
  this.dragPass.updateDrag(targetWorldPos);
3140
- this.reheat(.01);
3161
+ this.reheat(.1);
3141
3162
  }
3142
3163
  endDrag() {
3143
3164
  this.dragPass.endDrag();
@@ -3850,6 +3871,7 @@ var Engine = class {
3850
3871
  this.animationFrameId = null;
3851
3872
  this.isRunning = false;
3852
3873
  this.boundResizeHandler = null;
3874
+ this.smoothedTooltipPos = null;
3853
3875
  this.animate = () => {
3854
3876
  if (!this.isRunning) return;
3855
3877
  const deltaTime = this.clock.update();
@@ -3940,6 +3962,33 @@ var Engine = class {
3940
3962
  this.simulationBuffers.setOriginalPositions(newOriginalPositions);
3941
3963
  }
3942
3964
  /**
3965
+ * Set target positions for elastic force (tree layout, etc.)
3966
+ * Writes positions into the "original positions" GPU buffer that ElasticPass reads from,
3967
+ * without touching current positions or node states.
3968
+ * Must be called AFTER applyPreset() since updateNodeStates() overwrites original positions.
3969
+ */
3970
+ setTargetPositions(targets) {
3971
+ if (!this.simulationBuffers.isReady()) return;
3972
+ const nodes = this.graphStore.getNodes();
3973
+ const currentPositions = this.simulationBuffers.readPositions();
3974
+ const newOriginalPositions = new Float32Array(nodes.length * 4);
3975
+ nodes.forEach((node, i) => {
3976
+ const target = targets.get(node.id);
3977
+ const state = currentPositions[i * 4 + 3];
3978
+ if (target) {
3979
+ newOriginalPositions[i * 4] = target.x;
3980
+ newOriginalPositions[i * 4 + 1] = target.y;
3981
+ newOriginalPositions[i * 4 + 2] = target.z;
3982
+ } else {
3983
+ newOriginalPositions[i * 4] = currentPositions[i * 4];
3984
+ newOriginalPositions[i * 4 + 1] = currentPositions[i * 4 + 1];
3985
+ newOriginalPositions[i * 4 + 2] = currentPositions[i * 4 + 2];
3986
+ }
3987
+ newOriginalPositions[i * 4 + 3] = state;
3988
+ });
3989
+ this.simulationBuffers.setOriginalPositions(newOriginalPositions);
3990
+ }
3991
+ /**
3943
3992
  * Reheat the simulation
3944
3993
  */
3945
3994
  reheat(alpha = 1) {
@@ -3992,13 +4041,20 @@ var Engine = class {
3992
4041
  this.forceSimulation.reheat();
3993
4042
  }
3994
4043
  }
4044
+ if (this.forceSimulation.config.enableRadial) {
4045
+ const rootCategories = preset.radial?.rootCategories ?? ["root"];
4046
+ this.computeRadialDepths(rootCategories);
4047
+ }
3995
4048
  if (preset.links) {
3996
4049
  const linkRenderer = this.graphScene.getLinkRenderer();
3997
- if (linkRenderer) linkRenderer.setOptions({ alpha: { default: preset.links.opacity } });
4050
+ if (linkRenderer) linkRenderer.setOptions({
4051
+ alpha: { default: preset.links.opacity },
4052
+ noiseStrength: preset.links.noiseStrength
4053
+ });
3998
4054
  }
3999
4055
  }
4000
4056
  /**
4001
- *
4057
+ *
4002
4058
  * Start render loop
4003
4059
  */
4004
4060
  start() {
@@ -4041,6 +4097,7 @@ var Engine = class {
4041
4097
  highlightNodes(nodeIds) {
4042
4098
  this.interactionManager.searchHighlightIds = nodeIds;
4043
4099
  this.graphScene.getNodeRenderer()?.highlight(nodeIds);
4100
+ this.graphScene.getLinkRenderer()?.highlightConnectedToNodes(new Set(nodeIds));
4044
4101
  }
4045
4102
  /**
4046
4103
  * Clear highlights
@@ -4048,6 +4105,7 @@ var Engine = class {
4048
4105
  clearHighlights() {
4049
4106
  this.interactionManager.searchHighlightIds = [];
4050
4107
  this.graphScene.getNodeRenderer()?.clearHighlights();
4108
+ this.graphScene.getLinkRenderer()?.clearHighlights();
4051
4109
  }
4052
4110
  /**
4053
4111
  * Get node position in 3D space
@@ -4061,6 +4119,7 @@ var Engine = class {
4061
4119
  }
4062
4120
  /**
4063
4121
  * Get node screen position from world position
4122
+ * Returns position and visibility info (whether node is on screen and in front of camera)
4064
4123
  */
4065
4124
  getNodeScreenPosition(nodeId) {
4066
4125
  const pos = this.getNodePosition(nodeId);
@@ -4069,18 +4128,40 @@ var Engine = class {
4069
4128
  const size = new THREE.Vector2();
4070
4129
  this.graphScene.renderer.getSize(size);
4071
4130
  const rect = this.graphScene.renderer.domElement.getBoundingClientRect();
4131
+ const x = (screenPos.x + 1) / 2 * size.x + rect.left;
4132
+ const y = -(screenPos.y - 1) / 2 * size.y + rect.top;
4133
+ const behindCamera = screenPos.z > 1;
4134
+ const outsideViewport = x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
4072
4135
  return {
4073
- x: (screenPos.x + 1) / 2 * size.x + rect.left,
4074
- y: -(screenPos.y - 1) / 2 * size.y + rect.top
4136
+ x,
4137
+ y,
4138
+ visible: !behindCamera && !outsideViewport
4075
4139
  };
4076
4140
  }
4077
4141
  /**
4078
4142
  * Update sticky tooltip position to follow node during camera movement
4143
+ * Uses lerp smoothing to filter out micro-jitter from force simulation
4079
4144
  */
4080
4145
  updateStickyTooltipPosition() {
4081
- if (!this.interactionManager.isTooltipSticky || !this.interactionManager.stickyNodeId) return;
4146
+ if (!this.interactionManager.isTooltipSticky || !this.interactionManager.stickyNodeId) {
4147
+ this.smoothedTooltipPos = null;
4148
+ return;
4149
+ }
4082
4150
  const screenPos = this.getNodeScreenPosition(this.interactionManager.stickyNodeId);
4083
- if (screenPos) this.interactionManager.tooltipManager.updateMainTooltipPos(screenPos.x, screenPos.y);
4151
+ if (!screenPos) return;
4152
+ this.interactionManager.tooltipManager.setMainTooltipVisibility(screenPos.visible);
4153
+ if (!screenPos.visible) return;
4154
+ const targetX = screenPos.x;
4155
+ const targetY = screenPos.y;
4156
+ if (!this.smoothedTooltipPos || this.smoothedTooltipPos.nodeId !== this.interactionManager.stickyNodeId) this.smoothedTooltipPos = {
4157
+ x: targetX,
4158
+ y: targetY,
4159
+ nodeId: this.interactionManager.stickyNodeId
4160
+ };
4161
+ const lerpFactor = .35;
4162
+ this.smoothedTooltipPos.x += (targetX - this.smoothedTooltipPos.x) * lerpFactor;
4163
+ this.smoothedTooltipPos.y += (targetY - this.smoothedTooltipPos.y) * lerpFactor;
4164
+ this.interactionManager.tooltipManager.updateMainTooltipPos(this.smoothedTooltipPos.x, this.smoothedTooltipPos.y);
4084
4165
  }
4085
4166
  /**
4086
4167
  * Programmatically select a node (highlight + tooltip)
@@ -4095,6 +4176,7 @@ var Engine = class {
4095
4176
  const connectedIds = this.graphStore.getConnectedNodeIds(node.id);
4096
4177
  connectedIds.push(node.id);
4097
4178
  nodeRenderer?.highlight(connectedIds);
4179
+ this.smoothedTooltipPos = null;
4098
4180
  const screenPos = this.getNodeScreenPosition(nodeId);
4099
4181
  if (screenPos) this.interactionManager.tooltipManager.showFull(node, screenPos.x, screenPos.y);
4100
4182
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@fusefactory/fuse-three-forcegraph",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
+ "packageManager": "pnpm@10.6.4",
4
5
  "description": "A high-performance GPU-accelerated force-directed graph visualization library built with Three.js. Features a modular pass-based architecture for flexible and extensible force simulations.",
5
6
  "author": "Matteo Amerena",
6
7
  "license": "ISC",
@@ -14,6 +15,10 @@
14
15
  "files": [
15
16
  "dist"
16
17
  ],
18
+ "scripts": {
19
+ "build": "tsdown",
20
+ "watch": "tsdown --watch"
21
+ },
17
22
  "dependencies": {
18
23
  "@rnbo/js": "^1.4.2",
19
24
  "camera-controls": "^3.1.1",
@@ -25,6 +30,11 @@
25
30
  "typescript": "^5.9.3",
26
31
  "vite-plugin-glsl": "^1.5.5"
27
32
  },
33
+ "pnpm": {
34
+ "onlyBuiltDependencies": [
35
+ "esbuild"
36
+ ]
37
+ },
28
38
  "repository": {
29
39
  "type": "git",
30
40
  "url": "git+https://github.com/fusefactory/fuse-three-forcegraph.git"
@@ -32,9 +42,5 @@
32
42
  "bugs": {
33
43
  "url": "https://github.com/fusefactory/fuse-three-forcegraph/issues"
34
44
  },
35
- "homepage": "https://github.com/fusefactory/fuse-three-forcegraph#readme",
36
- "scripts": {
37
- "build": "tsdown",
38
- "watch": "tsdown --watch"
39
- }
40
- }
45
+ "homepage": "https://github.com/fusefactory/fuse-three-forcegraph#readme"
46
+ }