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,
@@ -21,6 +21,9 @@ import { CytoscapeLayout, GraphologyLayout, LayoutFactory, Layouts } from '../..
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';
import { ConstructionOutlined } from '@mui/icons-material';
type Props = {
onClick: (event?: { node: NodeTypeD3; pos: IPointData }) => void;
@@ -43,7 +46,7 @@ type LayoutState = 'reset' | 'running' | 'paused';
export const NLPixi = (props: Props) => {
const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: IPointData } | undefined>();
const [popups, setPopups] = useState<{ node: NodeType; pos: IPointData }[]>([]);
const [popups, setPopups] = useState<{ node: NodeTypeD3; pos: IPointData }[]>([]);
const [assetsLoaded, setAssetsLoaded] = useState(false);
const app = useMemo(
@@ -68,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: [] });
@@ -122,61 +123,64 @@ 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) {
if (props.configuration.showPopUpOnHover) return;
(event as any).mouseDownTimeStamp = event.timeStamp;
},
onDragMove(movementX: number, movementY: number) {
if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return;
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]);
}
onMouseUpNode(event: FederatedPointerEvent) {
if (props.configuration.showPopUpOnHover) return;
// 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;
}
const sprite = event.target as Sprite;
const node = (sprite as any).node as NodeTypeD3;
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();
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));
}
}
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 (props.configuration.showPopUpOnHover) return;
// 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) {
if (!props.configuration.showPopUpOnHover) return;
const sprite = event.target as Sprite;
const node = (sprite as any).node as NodeTypeD3;
if (
mouseInCanvas.current &&
viewport?.current &&
@@ -188,11 +192,19 @@ export const NLPixi = (props: Props) => {
}
},
onUnHover() {
if (!props.configuration.showPopUpOnHover) return;
setQuickPopup(undefined);
},
onPan() {
setPopups([]);
props.onClick();
onMoved(event: MovedEvent) {
if (props.configuration.showPopUpOnHover) return;
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]);
},
}));
@@ -234,21 +246,6 @@ 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 gfx = nodeMap.current.get(node._id);
if (!gfx) return;
@@ -267,20 +264,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 {
@@ -307,23 +290,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);
sprite.scale.set(scale, scale);
sprite.anchor.set(0.5, 0.5);
nodeMap.current.set(node._id, gfx);
nodeLayer.addChild(gfx);
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*/
@@ -512,7 +500,6 @@ export const NLPixi = (props: Props) => {
};
const update = (forceClear = false) => {
setPopups([]);
if (!props.graph || !ref.current) return;
if (props.graph) {
@@ -601,14 +588,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', (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();
@@ -639,10 +625,25 @@ export const NLPixi = (props: Props) => {
}
layoutAlgorithm.current.layout(graphologyGraph, boundingBox);
};
return (
<>
{mouseInCanvas.current && popups.map((popup) => <NLPopup onClose={() => {}} data={popup} key={popup.node._id} />)}
{props.showPopupsOnHover && quickPopup && <NLPopup onClose={() => {}} data={quickPopup} />}
{popups.map((popup) => (
<Tooltip key={popup.node._id} open={true} boundaryElement={ref} showArrow={true}>
<TooltipTrigger x={popup.pos.x} y={popup.pos.y} />
<TooltipContent>
<NLPopup onClose={() => {}} data={{node: props.graph.nodes[popup.node._id], pos: popup.pos}} key={popup.node._id} />
</TooltipContent>
</Tooltip>
))}
{quickPopup != null &&
<Tooltip key={quickPopup.node._id} open={true} boundaryElement={ref} showArrow={true}>
<TooltipTrigger x={quickPopup.pos.x} y={quickPopup.pos.y} />
<TooltipContent>
<NLPopup onClose={() => {}} data={{node: props.graph.nodes[quickPopup.node._id], pos: quickPopup.pos}} key={quickPopup.node._id} />
</TooltipContent>
</Tooltip>
}
<div
className="h-full w-full overflow-hidden"
ref={ref}
Loading