Skip to content

Commit

Permalink
Graph HTML renderer (WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
Godefroy committed Oct 2, 2024
1 parent 4d05130 commit d809d60
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 181 deletions.
12 changes: 8 additions & 4 deletions packages/webapp/src/features/graph/CirclesHTMLGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useCirclesGraph, { CirclesGraphProps } from './hooks/useCirclesGraph'
import CirclesTitles from './renderers/html/CirclesTitles'
import Nodes from './renderers/html/Nodes'
import { Panzoom } from './renderers/html/Panzoom'
import { useNodeCursor } from './renderers/html/hooks/useNodeCursor'

// Force reset with fast refresh
// @refresh reset
Expand All @@ -19,15 +20,17 @@ export default forwardRef<CirclesGraph | undefined, CirclesGraphProps>(
// Expose ref
useImperativeHandle(ref, () => graph)

const focusWidth =
// Compute graph min size
const cropWidth =
props.width - (props.focusCrop?.left || 0) - (props.focusCrop?.right || 0)

const focusHeight =
const cropHeight =
props.height -
(props.focusCrop?.top || 0) -
(props.focusCrop?.bottom || 0)
const graphMinSize = Math.min(cropWidth, cropHeight)

const graphMinSize = Math.min(focusWidth, focusHeight)
// Cursor on nodes
const cursor = useNodeCursor(graph)

// Click outside => unselect circle
const handleClickOutside = (event: React.MouseEvent<HTMLDivElement>) => {
Expand All @@ -44,6 +47,7 @@ export default forwardRef<CirclesGraph | undefined, CirclesGraphProps>(
style={
{
'--graph-min-size': graphMinSize,
'--node-cursor': cursor,
} as React.CSSProperties
}
onClick={handleClickOutside}
Expand Down
3 changes: 2 additions & 1 deletion packages/webapp/src/features/graph/graphs/CirclesGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ export abstract class CirclesGraph extends Graph<CircleFullFragment[]> {

// Sort by depth and Y, then raise
.sort((a, b) =>
a.depth === b.depth ? a.y - b.y : a.depth < b.depth ? -1 : 1
// a.depth === b.depth ? a.y - b.y :
a.depth < b.depth ? -1 : 1
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default memo(function CirclesTitles({ graph }: Props) {

return nodes.map((node) =>
node.data.type === NodeType.Circle ? (
<CircleTitleElement key={node.data.id} graph={graph} node={node} />
<CircleTitleElement key={node.data.id} node={node} />
) : null
)
})
7 changes: 1 addition & 6 deletions packages/webapp/src/features/graph/renderers/html/Nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,7 @@ export default memo(function Nodes({ graph }: Props) {
selected={selected}
/>
) : node.data.type === NodeType.Member ? (
<MemberElement
key={node.data.id}
graph={graph}
node={node}
selected={selected}
/>
<MemberElement key={node.data.id} graph={graph} node={node} />
) : null
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { isPointInsideCircle } from '../../svg/helpers/isPointInsideCircle'

interface Position {
Expand All @@ -20,87 +20,101 @@ export function useDragNode(graph: Graph, node: NodeData) {
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) &&
const canDrag = useMemo(
() =>
// 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,
}
node.data.id.indexOf('_') === -1,
[node, graph]
)

const handleMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const isDragging =
canDrag &&
// Disable when mousewheel is pressed
event.button !== 1 &&
// Control/Command key is pressed
(event.ctrlKey || event.metaKey)
if (!isDragging) 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')
})
.filter(truthy)

// Add classes
dragNodes.current[0].element.classList.add('drag-node')
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('mousemove', handleMouseMove)
},
[canDrag, graph, node]
)

const handleMouseUp = useCallback(() => {
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.add('dragging')
d.element.classList.remove('dragging')
})
dragTarget.current?.element.classList.remove('drag-target')

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

// Remove classes
dragNodes.current[0]?.element.classList.remove('drag-node')
// Reset dragged circles
const actionMoved = false
if (dragNodes.current && !actionMoved) {
dragNodes.current.forEach((d) => {
d.element.classList.remove('dragging')
setTimeout(() => {
d.element.style.transform = `translate(${d.node.x - d.node.r}px, ${
d.node.y - d.node.r
}px)`
}, 0)
})
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) => {
// Reset refs
dragOrigin.current = undefined
dragNodes.current = []
dragTargets.current = []
dragTarget.current = undefined
}, [graph])

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

const { k } = graph.zoomTransform
Expand All @@ -122,13 +136,21 @@ export function useDragNode(graph: Graph, node: NodeData) {
target.element.classList.add('drag-target')
}
}
}
},
[graph]
)

document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('mousemove', handleMouseMove)
}
// Cleanup event listeners on unmount
useEffect(
() => () => {
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('mousemove', handleMouseMove)
},
[graph]
)

return {
canDrag,
handleMouseDown,
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Graph } from '@/graph/graphs/Graph'
import { useCallback, useEffect, useRef, useState } from 'react'

export function useNodeCursor(graph?: Graph) {
const [cursor, setCursor] = useState('pointer')
const cursorRef = useRef('pointer')

const updateCursor = useCallback(
(event: KeyboardEvent) => {
if (!graph) return
const {
onCircleClick,
onCircleMove,
onCircleCopy,
onMemberClick,
onMemberMove,
onMemberAdd,
} = graph.params.events
const canClick = !!(onCircleClick && onMemberClick)
const canMove = !!(onCircleMove && onMemberMove)
const canCopy = !!(onCircleCopy && onMemberAdd)

const shift = event.shiftKey
const ctrl = event.ctrlKey || event.metaKey
let cursor = cursorRef.current

if (canCopy && ctrl && shift) {
cursor = 'copy'
} else if (canMove && ctrl) {
cursor = 'grab'
} else if (canClick) {
cursor = 'pointer'
} else {
cursor = 'default'
}
if (cursorRef.current !== cursor) {
cursorRef.current = cursor
setCursor(cursor)
}
},
[graph]
)

useEffect(() => {
document.addEventListener('keydown', updateCursor)
document.addEventListener('keyup', updateCursor)
return () => {
document.removeEventListener('keydown', updateCursor)
document.removeEventListener('keyup', updateCursor)
}
}, [updateCursor])

return cursor
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export default function CircleElement({ graph, node, selected }: Props) {
graph={graph}
node={node}
selected={selected}
textAlign="center"
onClick={() => node.data.entityId && onCircleClick?.(node.data.entityId)}
>
<Text
Expand All @@ -44,11 +43,13 @@ export default function CircleElement({ graph, node, selected }: Props) {
sx={{ textWrap: 'balance' }}
position="absolute"
bottom={`${node.r * 2 - 10}px`}
transition="bottom 1500ms ease-out"
pointerEvents="none"
>
{node.data.name}
</Text>
<CircleLeadersElement node={node} />

{node.data.participants && <CircleLeadersElement node={node} />}
</NodeElement>
)
}
Loading

0 comments on commit d809d60

Please sign in to comment.