Skip to content

Commit

Permalink
Add drag gesture in graph HTML renderer (WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
Godefroy committed Sep 30, 2024
1 parent f2f0fc1 commit 4d05130
Show file tree
Hide file tree
Showing 13 changed files with 236 additions and 21 deletions.
5 changes: 2 additions & 3 deletions packages/webapp/src/features/graph/graphs/CirclesGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,12 @@ export abstract class CirclesGraph extends Graph<CircleFullFragment[]> {
}

updateData(circles: CircleFullFragment[]) {
const firstDraw = !this.inputData
super.updateData(circles)
const data = this.prepareData(circles)

// Pack data with d3.pack
const root = this.packData(data)
const firstDraw = !this.d3Root.select('.circle').node()

// Get all nodes under root and rescale them
const nodesMap = root.descendants()
Expand All @@ -219,8 +219,7 @@ export abstract class CirclesGraph extends Graph<CircleFullFragment[]> {
if (firstDraw) {
setTimeout(
() => this.zoomTo(root.x, root.y, this.focusCircleScale(root)),
0,
true
0
)
}

Expand Down
7 changes: 6 additions & 1 deletion packages/webapp/src/features/graph/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ export abstract class Graph<
// Zoom
this.zoomBehaviour = d3
.zoom<RootElement, any>()
.filter(() => !this.zoomDisabled) // Listen also to mouse wheel
.filter(
(event) =>
!this.zoomDisabled &&
// Control/Command key is pressed
!(event.ctrlKey || event.metaKey)
)
.scaleExtent(settings.zoom.scaleExtent as [number, number])
.on('zoom', (event) => {
if (this.unmounted) return
Expand Down
11 changes: 11 additions & 0 deletions packages/webapp/src/features/graph/hooks/useMounted.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react'

export default function useMounted() {
const [mounted, setMounted] = useState(false)

useEffect(() => {
setMounted(true)
}, [])

return mounted
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function Panzoom({ graph, children }: Props) {
<Box
position="relative"
transformOrigin="top left"
userSelect="none"
style={
{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.k})`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Graph } from '@/graph/graphs/Graph'
import { NodeData, NodeType } from '@/graph/types'
import { truthy } from '@rolebase/shared/helpers/truthy'
import React, { useRef } from 'react'
import { isPointInsideCircle } from '../../svg/helpers/isPointInsideCircle'

interface Position {
x: number
y: number
}

interface DragNode {
node: NodeData
element: HTMLDivElement
}

export function useDragNode(graph: Graph, node: NodeData) {
const dragOrigin = useRef<Position>()
const dragNodes = useRef<DragNode[]>([])
const dragTargets = useRef<DragNode[]>([])
const dragTarget = useRef<DragNode | undefined>()

const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
const canDrag =
// Disable when mousewheel is pressed
event.button !== 1 &&
// Control/Command key is pressed
(event.ctrlKey || event.metaKey) &&
// Disable when events are not provided
graph.params.events.onCircleMove &&
graph.params.events.onMemberMove &&
// Disable for invited circles (links)
node.data.id.indexOf('_') === -1

// Can't drag, click
if (!canDrag) return

// Register mouse position
dragOrigin.current = { x: event.clientX, y: event.clientY }

// Register nodes to drag
const descendants = node.descendants()
dragNodes.current = [node, ...descendants]
.map((d) => {
const element = getNodeElement(d)
if (!element) return
return {
node: d,
element,
}
})
.filter(truthy)

// Register targets
dragTargets.current = graph.nodes
.filter(
(d) => !descendants.includes(d) && d.data.type === NodeType.Circle
)
.map((d) => {
const element = getNodeElement(d)
if (!element) return
return {
node: d,
element,
}
})
.filter(truthy)

// Add classes
dragNodes.current[0].element.classList.add('drag-node')
dragNodes.current.forEach((d) => {
d.element.classList.add('dragging')
})

const handleMouseUp = () => {
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('mousemove', handleMouseMove)

// Remove classes
dragNodes.current[0]?.element.classList.remove('drag-node')
dragNodes.current.forEach((d) => {
d.element.classList.remove('dragging')
})
dragTarget.current?.element.classList.remove('drag-target')

// Reset dragged circles
const actionMoved = false
if (dragNodes.current && !actionMoved) {
dragNodes.current.forEach((d) => {
setTimeout(() => {
d.element.style.transform = ''
}, 0)
})
}

// Reset refs
dragOrigin.current = undefined
dragNodes.current = []
dragTargets.current = []
dragTarget.current = undefined
}

const handleMouseMove = (event: MouseEvent) => {
if (!dragOrigin.current || !dragTargets.current) return

const { k } = graph.zoomTransform
const dX = (event.clientX - dragOrigin.current.x) / k
const dY = (event.clientY - dragOrigin.current.y) / k

dragNodes.current.forEach((d) => {
d.element.style.transform = `translate(${d.node.x - d.node.r + dX}px, ${
d.node.y - d.node.r + dY
}px)`
})

const target = getTargetNodeData(dragTargets.current, event, graph)

if (target !== dragTarget.current) {
dragTarget.current?.element.classList.remove('drag-target')
if (target) {
dragTarget.current = target
target.element.classList.add('drag-target')
}
}
}

document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('mousemove', handleMouseMove)
}

return {
handleMouseDown,
}
}

function getNodeElement(node: NodeData) {
return document.getElementById(`node-${node.data.id}`) as HTMLDivElement
}

function getTargetNodeData(
targetNodes: DragNode[],
event: MouseEvent,
graph: Graph
): DragNode | null {
const position = getDragEventPosition(event, graph)

// Get circles under the mouse
const currentTargets = targetNodes.filter(({ node }) =>
isPointInsideCircle(position.x, position.y, node.x, node.y, node.r)
)

// Get last descendants under the mouse
return (
currentTargets.reduce<{ max: number; dragNode?: DragNode }>(
(acc, dragNode) =>
!dragNode || dragNode.node.depth > acc.max
? { max: dragNode.node.depth, dragNode }
: acc,
{ max: 0 }
).dragNode || null
)
}

function getDragEventPosition(event: MouseEvent, graph: Graph) {
const { x, y, k } = graph.zoomTransform
const graphRect = (graph.element as HTMLDivElement).getBoundingClientRect()

return {
x: (event.clientX - graphRect.left - x) / k,
y: (event.clientY - graphRect.top - y) / k,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function CircleElement({ graph, node, selected }: Props) {

return (
<NodeElement
graph={graph}
node={node}
selected={selected}
textAlign="center"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NodeData } from '@/graph/types'
import { Circle, Text } from '@chakra-ui/react'
import { Participant } from '@rolebase/shared/model/member'
import React, { useMemo } from 'react'
import { getDarkColor, getLightColor } from '../colors'
import { getDarkColor, getLightColor } from '../utils/colors'

interface Props {
node: NodeData
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CirclesGraph } from '@/graph/graphs/CirclesGraph'
import useMounted from '@/graph/hooks/useMounted'
import { NodeData, NodeType } from '@/graph/types'
import { Text } from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
Expand All @@ -18,14 +19,10 @@ interface Size {
}

export default function CircleTitleElement({ graph, node }: Props) {
const mounted = useMounted()
const parent =
node.data.type === NodeType.Member ? node.parent?.parent : node.parent

const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])

// Text size
const textRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<Size | undefined>(undefined)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function MemberElement({ graph, node, selected }: Props) {

return (
<NodeElement
graph={graph}
node={node}
role="group"
display="var(--display-members, flex)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,62 @@
import { CirclesGraph } from '@/graph/graphs/CirclesGraph'
import useMounted from '@/graph/hooks/useMounted'
import { NodeData, NodeType } from '@/graph/types'
import { BoxProps, Circle } from '@chakra-ui/react'
import React, { useEffect, useState } from 'react'
import { getDarkColor, getLightColor } from '../colors'
import React from 'react'
import { useDragNode } from '../hooks/useDragNode'
import { getDarkColor, getLightColor } from '../utils/colors'

interface Props extends BoxProps {
graph: CirclesGraph
node: NodeData
selected?: boolean
children: React.ReactNode
}

export default function NodeElement({
graph,
node,
selected,
children,
...boxProps
}: Props) {
const mounted = useMounted()

const parent =
node.data.type === NodeType.Member ? node.parent?.parent : node.parent
const depth = node.depth
const hue = node.data.colorHue

const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Drag & drop
const { handleMouseDown } = useDragNode(graph, node)

return (
<Circle
className="circle"
id={`node-${node.data.id}`}
position="absolute"
size={mounted ? `${node.r * 2}px` : '0px'}
transform={
mounted || !parent
? `translate(${node.x - node.r}px, ${node.y - node.r}px)`
: `translate(${parent.x}px, ${parent.y}px)`
}
transition="transform 300ms ease-out, width 300ms ease-out, height 300ms ease-out"
transition={`
transform 300ms ease-out,
width 300ms ease-out,
height 300ms ease-out,
box-shadow 300ms ease-out,
opacity 300ms ease-out
`}
cursor="pointer"
bgColor={getLightColor(94, depth, hue)}
boxShadow={`0 1px 2px ${getLightColor(75, depth, hue)}`}
outline={`${
selected ? 'calc(4px / var(--zoom-scale))' : 0
} solid ${getLightColor(75, depth, hue)}`}
_dark={{
bg: getDarkColor(16, depth, hue),
outlineColor: getDarkColor(35, depth, hue),
boxShadow: `0 1px 2px ${getDarkColor(35, depth, hue)}`,
}}
_hover={
!selected
Expand All @@ -56,7 +69,23 @@ export default function NodeElement({
}
: undefined
}
ondrg
onMouseDown={handleMouseDown}
sx={{
'&.drag-node': {
boxShadow: `0 10px 10px ${getLightColor(75, depth, hue)}`,
_dark: {
boxShadow: `0 10px 10px ${getDarkColor(35, depth, hue)}`,
},
},
'&.dragging': {
opacity: 0.7,
zIndex: 100,
transition: 'none !important',
},
'&.drag-target': {
outlineWidth: 'calc(8px / var(--zoom-scale))',
},
}}
{...boxProps}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ export class MouseCircleElement extends AbstractCircleElement {
events.onCircleMove &&
events.onMemberMove &&
// Disable for invited circles (links)
(d.data.parentId === d.parent?.data.id ||
d.data.parentId === d.parent?.parent?.data.id)
d.data.id.indexOf('_') === -1
)
})
.on('start', function (event, dragNode) {
Expand Down

0 comments on commit 4d05130

Please sign in to comment.