diff --git a/apps/web/public/assets/sprite_selected.png b/apps/web/public/assets/sprite_selected.png index bd57dcc236de77693873365fdf8d007f2349d2ba..1717d0ce9b6570151ce5f68bacb12864bc542e00 100644 Binary files a/apps/web/public/assets/sprite_selected.png and b/apps/web/public/assets/sprite_selected.png differ diff --git a/apps/web/public/assets/sprite_selected_square.png b/apps/web/public/assets/sprite_selected_square.png index eb7a57e4f70d60c5096d07f0cd53803454285099..8e6a75992f913fa454c18c4138c8a3883791f98f 100644 Binary files a/apps/web/public/assets/sprite_selected_square.png and b/apps/web/public/assets/sprite_selected_square.png differ diff --git a/libs/shared/lib/components/tooltip/Tooltip.tsx b/libs/shared/lib/components/tooltip/Tooltip.tsx index 19ca660cfbcf173470a8e424ccf9c0d3e710de6e..3541d2516820777d7eea188a93890cfee3ea1d78 100644 --- a/libs/shared/lib/components/tooltip/Tooltip.tsx +++ b/libs/shared/lib/components/tooltip/Tooltip.tsx @@ -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> ); }); diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 6357b96bd019e268ae1d5913a5c23858a2d1c55f..9d59adebdfac5c26fcaa2c8950bba76ebf210497 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -1,6 +1,6 @@ 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} diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx index 2ff3cd5f6eac05f8a3ad84a1157c2c3425c0b0dc..db7f37d0df74b76c270de20334b39cae891c5fb8 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx @@ -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">