Skip to content
Snippets Groups Projects

feat: use floating ui for node link tooltips

Merged Dennis Collaris requested to merge feat/nodelink-tooltips into main
1 file
+ 78
115
Compare changes
  • Side-by-side
  • Inline
import { GraphType, GraphTypeD3, LinkType, LinkTypeD3, NodeType, NodeTypeD3 } from '../types';
import { dataColors, visualizationColors } from 'config';
import { ReactEventHandler, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import {
Application,
AssetsBundle,
@@ -22,6 +22,7 @@ import { MultiGraph } from 'graphology';
import { Viewport } from 'pixi-viewport';
import { NodelinkVisProps } from '../nodelinkvis';
import { Tooltip, TooltipContent, TooltipTrigger } from '@graphpolaris/shared/lib/components/tooltip';
import { MovedEvent } from 'pixi-viewport/dist/types';
type Props = {
@@ -70,8 +71,6 @@ export const NLPixi = (props: Props) => {
const mouseInCanvas = useRef<boolean>(false);
const isSetup = useRef(false);
const ml = useML();
const dragging = useRef<{ node: NodeTypeD3; gfx: Sprite } | null>(null);
const onlyClicked = useRef(false);
const searchResults = useSearchResultData();
const graph = useRef<GraphTypeD3>({ nodes: [], links: [] });
@@ -124,71 +123,58 @@ export const NLPixi = (props: Props) => {
const imperative = useRef<any>(null);
const mouseClickThreshold = 200; // Time between mouse up and down events that is considered a click, and not a drag.
useImperativeHandle(imperative, () => ({
onDragStart(node: NodeTypeD3, gfx: Sprite) {
dragging.current = { node, gfx };
onlyClicked.current = true;
// todo: graphology does not support fixed nodes
// todo: after vis-settings panel is there, we should to also support the original d3 force to allow interactivity if needed
if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return;
if (viewport.current) viewport.current.pause = true;
onMouseDown(event: FederatedPointerEvent) {
(event as any).mouseDownTimeStamp = event.timeStamp;
},
onMouseUpNode(event: FederatedPointerEvent) {
// If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups.
const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp;
if(holdDownTime > mouseClickThreshold) {
return;
}
onDragMove(movementX: number, movementY: number) {
if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return;
const sprite = event.target as Sprite;
const node = (sprite as any).node as NodeType;
for (const idx in popups) {
const p = popups[idx];
p.pos.x += movementX / (viewport.current?.scaled || 1);
p.pos.y += movementY / (viewport.current?.scaled || 1);
popups[idx] = p;
setPopups([...popups]);
}
if (dragging.current) {
onlyClicked.current = false;
if (quickPopup) setQuickPopup(undefined);
const idx = popups.findIndex((p) => p.node._id === dragging.current?.node._id);
if (idx >= 0) {
const p = popups[idx];
p.pos.x += movementX / (viewport.current?.scaled || 1);
p.pos.y += movementY / (viewport.current?.scaled || 1);
popups[idx] = p;
setPopups([...popups]);
if (event.shiftKey) {
setPopups([...popups, { node: node, pos: toGlobal(node) }]);
} else {
setPopups([{ node: node, pos: toGlobal(node) }]);
for (const popup of popups) {
const sprite = nodeMap.current.get(popup.node._id) as Sprite;
sprite.texture = Assets.get(textureId(false));
}
if (!dragging.current.node.fx) dragging.current.node.fx = dragging.current.node.x || 0;
if (!dragging.current.node.fy) dragging.current.node.fy = dragging.current.node.y || 0;
dragging.current.node.fx += movementX / (viewport.current?.scaled || 1);
dragging.current.node.fy += movementY / (viewport.current?.scaled || 1);
// force.simulation.alpha(0.1).restart();
}
sprite.texture = Assets.get(textureId(true));
props.onClick({ node: node, pos: toGlobal(node) });
event.stopPropagation();
},
onDragEnd() {
if (dragging.current) {
// dragging.current.node.fx = null;
// dragging.current.node.fy = null;
if (viewport.current) viewport.current.pause = false;
if (onlyClicked.current) {
onlyClicked.current = false;
if (popups.filter((d) => d.node._id === dragging.current?.node._id).length > 0) {
setPopups(popups.filter((p) => p.node._id !== dragging.current?.node._id));
props.onClick();
} else {
setPopups([...popups, { node: props.graph.nodes[dragging.current.node._id], pos: toGlobal(dragging.current.node) }]);
props.onClick({ node: dragging.current.node, pos: toGlobal(dragging.current.node) });
}
onMouseUpStage(event: FederatedPointerEvent) {
// If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups.
const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp;
if(holdDownTime < mouseClickThreshold) {
for (const popup of popups) {
const sprite = nodeMap.current.get(popup.node._id) as Sprite;
sprite.texture = Assets.get(textureId(false));
}
this.onHover(dragging.current.node);
dragging.current = null;
} else {
setPopups([]);
props.onClick();
}
},
onHover(node: NodeTypeD3) {
onHover(event: FederatedPointerEvent) {
const sprite = event.target as Sprite;
const node = (sprite as any).node as NodeType;
if (
mouseInCanvas.current &&
viewport?.current &&
@@ -202,10 +188,14 @@ export const NLPixi = (props: Props) => {
onUnHover() {
setQuickPopup(undefined);
},
onPan() {
setPopups([]);
props.onClick();
},
onMoved(event: MovedEvent) {
for (const popup of popups) {
if (popup.node.x == null || popup.node.y == null) continue;
popup.pos.x = event.viewport.transform.position.x + (popup.node.x * event.viewport.scale.x);
popup.pos.y = event.viewport.transform.position.y + (popup.node.y * event.viewport.scale.y);
}
setPopups([...popups]);
}
}));
function resize() {
@@ -246,22 +236,7 @@ export const NLPixi = (props: Props) => {
} else return { x: 0, y: 0 };
}
function onDragStart(event: FederatedPointerEvent, node: NodeTypeD3, gfx: Sprite) {
event.stopPropagation();
if (imperative.current) imperative.current.onDragStart(node, gfx);
}
function onDragMove(event: FederatedPointerEvent) {
event.stopPropagation();
if (imperative.current) imperative.current.onDragMove(event.movementX, event.movementY);
}
function onDragEnd(event: FederatedPointerEvent) {
event.stopPropagation();
if (imperative.current) imperative.current.onDragEnd();
}
const updateNode = (node: NodeTypeD3) => {
const updateNode = (node: NodeType) => {
const gfx = nodeMap.current.get(node._id);
if (!gfx) return;
@@ -279,20 +254,6 @@ export const NLPixi = (props: Props) => {
gfx.position.set(node.x, node.y);
gfx.off('mouseover');
gfx.off('mousedown');
gfx.on('mouseover', (e) => {
e.stopPropagation();
e.preventDefault();
if (imperative.current) imperative.current.onHover(node);
});
gfx.on('mouseout', (e) => {
e.stopPropagation();
e.preventDefault();
if (imperative.current) imperative.current.onUnHover();
});
gfx.on('mousedown', (e) => onDragStart(e, node, gfx));
// if (!item.position) {
// item.position = new Point(node.x, node.y);
// } else {
@@ -319,23 +280,28 @@ export const NLPixi = (props: Props) => {
// Do not draw node if it has no position
if (node.x === undefined || node.y === undefined) return;
let gfx: Sprite;
let sprite: Sprite;
const texture = Assets.get(textureId());
gfx = new Sprite(texture);
sprite = new Sprite(texture);
gfx.tint = nodeColor(nodeMeta.type);
sprite.tint = nodeColor(nodeMeta.type);
const scale = (Math.max(nodeMeta.radius || 5, 5) / 70) * 2;
gfx.scale.set(scale, scale);
gfx.anchor.set(0.5, 0.5);
nodeMap.current.set(node._id, gfx);
nodeLayer.addChild(gfx);
sprite.scale.set(scale, scale);
sprite.anchor.set(0.5, 0.5);
sprite.eventMode = 'static';
sprite.on('mousedown', (e) => imperative.current.onMouseDown(e));
sprite.on('mouseup', (e) => imperative.current.onMouseUpNode(e));
sprite.on('mouseover', (e) => imperative.current.onHover(e));
sprite.on('mouseout', (e) => imperative.current.onUnHover(e));
nodeMap.current.set(node._id, sprite);
nodeLayer.addChild(sprite);
updateNode(node);
gfx.name = 'node_' + node._id;
gfx.eventMode = 'dynamic';
(sprite as any).node = node;
return gfx;
return sprite;
};
// /** UpdateRadius works just like UpdateColors, but also applies radius*/
@@ -524,7 +490,6 @@ export const NLPixi = (props: Props) => {
};
const update = (forceClear = false) => {
setPopups([]);
if (!props.graph || !ref.current) return;
if (props.graph) {
@@ -613,21 +578,13 @@ export const NLPixi = (props: Props) => {
viewport.current.addChild(linkGfx);
viewport.current.addChild(nodeLayer);
viewport.current.on('drag-start', (event) => {
imperative.current.onPan();
});
viewport.current.on('moved', () => {
for (const popup in popups) {
popup.pos.x += 10;
popup.pos.y += 10;
}
setPopups([...popups]);
viewport.current.on('moved', (event) => {
imperative.current.onMoved(event)
})
app.stage.eventMode = 'dynamic';
app.stage.on('pointerup', onDragEnd);
app.stage.on('mousemove', onDragMove);
app.stage.on('mouseup', onDragEnd);
app.stage.on('mousedown', (e) => imperative.current.onMouseDown(e));
app.stage.on('mouseup', (e) => imperative.current.onMouseUpStage(e));
nodeMap.current.clear();
linkGfx.clear();
@@ -660,12 +617,18 @@ export const NLPixi = (props: Props) => {
};
return (
<>
<Tooltip open={true}>
<Tooltip open={popups.length > 0}>
<TooltipTrigger x={popups[0]?.pos.x} y={popups[0]?.pos.y} />
<TooltipContent>
{popups[0] != null && <NLPopup onClose={() => {}} data={popups[0]} key={popups[0].node._id} />}
</TooltipContent>
</Tooltip>
<Tooltip open={popups.length > 1}>
<TooltipTrigger x={popups[1]?.pos.x} y={popups[1]?.pos.y} />
<TooltipContent>
{popups[1] != null && <NLPopup onClose={() => {}} data={popups[1]} key={popups[1].node._id} />}
</TooltipContent>
</Tooltip>
<div
className="h-full w-full overflow-hidden"
ref={ref}
Loading