Skip to content
Snippets Groups Projects
Commit f0f8e3e3 authored by Dennis Collaris's avatar Dennis Collaris Committed by Vink, S.A. (Sjoerd)
Browse files

feat(nl): use floating ui for node link tooltips

parent 6017e940
No related branches found
No related tags found
1 merge request!151feat(map_nodelink)
apps/web/public/assets/sprite_selected.png

646 B | W: | H:

apps/web/public/assets/sprite_selected.png

697 B | W: | H:

apps/web/public/assets/sprite_selected.png
apps/web/public/assets/sprite_selected.png
apps/web/public/assets/sprite_selected.png
apps/web/public/assets/sprite_selected.png
  • 2-up
  • Swipe
  • Onion skin
apps/web/public/assets/sprite_selected_square.png

100 B | W: | H:

apps/web/public/assets/sprite_selected_square.png

96 B | W: | H:

apps/web/public/assets/sprite_selected_square.png
apps/web/public/assets/sprite_selected_square.png
apps/web/public/assets/sprite_selected_square.png
apps/web/public/assets/sprite_selected_square.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -5,6 +5,8 @@ import {
offset,
flip,
shift,
hide,
arrow,
useHover,
useFocus,
useDismiss,
......@@ -12,6 +14,7 @@ import {
useInteractions,
useMergeRefs,
FloatingPortal,
FloatingArrow
} from '@floating-ui/react';
import type { Placement } from '@floating-ui/react';
import { FloatingDelayGroup } from '@floating-ui/react';
......@@ -21,6 +24,8 @@ interface TooltipOptions {
placement?: Placement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
boundaryElement?: React.RefObject<HTMLElement> | null;
showArrow?: boolean;
}
export function useTooltip({
......@@ -28,6 +33,8 @@ export function useTooltip({
placement = 'top',
open: controlledOpen,
onOpenChange: setControlledOpen,
boundaryElement = null,
showArrow = false
}: TooltipOptions = {}): {
open: boolean;
setOpen: (open: boolean) => void;
......@@ -38,8 +45,9 @@ export function useTooltip({
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;
const arrowRef = React.useRef<SVGSVGElement | null>(null);
const data = useFloating({
let config = {
placement,
open,
onOpenChange: setOpen,
......@@ -49,11 +57,25 @@ export function useTooltip({
flip({
crossAxis: placement.includes('-'),
fallbackAxisSideDirection: 'start',
padding: 5,
padding: 5
}),
shift({ padding: 5 }),
],
});
}
if (boundaryElement != null) {
const boundary = boundaryElement?.current ?? undefined;
config.middleware.find(x => x.name == 'flip')!.options[0].boundary = boundary;
config.middleware.find(x => x.name == 'shift')!.options[0].boundary = boundary;
config.middleware.push(hide({ boundary }));
}
if (showArrow) {
config.middleware.push(arrow({ element: arrowRef }));
}
const data = useFloating(config);
(data.refs as any).arrow = arrowRef;
const context = data.context;
......@@ -98,17 +120,48 @@ export function Tooltip({ children, ...options }: { children: React.ReactNode }
// This can accept any props as options, e.g. `placement`,
// or other positioning options.
const tooltip = useTooltip(options);
return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>;
return <TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>;
}
export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean }>(function TooltipTrigger(
{ children, asChild = false, ...props },
export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean, x?: number, y?: number }>(function TooltipTrigger(
{ children, asChild = false, x = null, y = null, ...props },
propRef,
) {
const context = useTooltipContext();
const childrenRef = (children as any).ref;
const childrenRef = React.useMemo(() => {
if (children == null) {
return null;
} else {
return (children as any).ref;
}
}, [children]);
const ref = useMergeRefs([context.data.refs.setReference, propRef, childrenRef]);
React.useEffect(() => {
if (x && y && context.data.refs.reference.current != null) {
const element = context.data.refs.reference.current as HTMLElement;
element.style.position = 'absolute';
const {x: offsetX, y: offsetY} = element.getBoundingClientRect();
element.getBoundingClientRect = () => {
return {
width: 0,
height: 0,
x: offsetX,
y: offsetY,
top: y + offsetY,
left: x + offsetX,
right: x + offsetX,
bottom: y + offsetY,
} as DOMRect
}
context.data.update();
}
}, [x, y]);
// `asChild` allows the user to pass any element as the anchor
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
......@@ -149,13 +202,21 @@ export const TooltipContent = React.forwardRef<
<FloatingPortal>
<div
ref={ref}
className={`z-50 max-w-64 overflow-hidden rounded bg-light px-2 py-1 shadow text-xs border border-secondary-200 text-dark animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2${className ? ` ${className}` : ''}`}
className={`z-50 max-w-64 rounded bg-light px-2 py-1 shadow text-xs border border-secondary-200 text-dark animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2${className ? ` ${className}` : ''}`}
style={{
...context.data.floatingStyles,
...style,
display: context.data.middlewareData.hide?.referenceHidden ? 'none' : 'block',
}}
{...context.interactions.getFloatingProps(props)}
/>
>
{ props.children }
{ context.data.middlewareData.arrow ? <FloatingArrow
ref={(context.data.refs as any).arrow}
context={context.data.context}
style={{fill: 'white'}}
/> : null }
</div>
</FloatingPortal>
);
});
......
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}
......
......@@ -11,9 +11,7 @@ export const NLPopup = (props: NodelinkPopupProps) => {
return (
<div
className="absolute card card-bordered z-50 bg-light rounded-none text-[0.9rem] min-w-[10rem] pointer-events-none"
// style={{ top: props.data.pos.y + 10, left: props.data.pos.x + 10 }}
style={{ transform: 'translate(' + (props.data.pos.x + 20) + 'px, ' + (props.data.pos.y + 10) + 'px)' }}
className="text-[0.9rem] min-w-[10rem]"
>
<div className="card-body p-0">
<span className="px-2.5 pt-2">
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment