diff --git a/packages/webapp/src/features/circle/pages/CirclesPage.tsx b/packages/webapp/src/features/circle/pages/CirclesPage.tsx index fbdf2815..482104c7 100644 --- a/packages/webapp/src/features/circle/pages/CirclesPage.tsx +++ b/packages/webapp/src/features/circle/pages/CirclesPage.tsx @@ -3,7 +3,7 @@ import { Title } from '@/common/atoms/Title' import { useElementSize } from '@/common/hooks/useElementSize' import useOverflowHidden from '@/common/hooks/useOverflowHidden' import useQueryParams from '@/common/hooks/useQueryParams' -import CirclesCanvasGraph from '@/graph/CirclesCanvasGraph' +import CirclesHTMLGraph from '@/graph/CirclesHTMLGraph' import CirclesSVGGraph from '@/graph/CirclesSVGGraph' import { GraphProvider } from '@/graph/contexts/GraphContext' import useCirclesEvents from '@/graph/hooks/useGraphEvents' @@ -133,7 +133,7 @@ export default function CirclesPage() { circles && boxSize && (beta ? ( - ( const canvasRef = useRef(null) // Instanciate graph - const graphRef = useCirclesGraph(canvasRef, props) + const graph = useCirclesGraph(canvasRef, props) + useRenderer(graph, (graph) => new CanvasRenderer(graph)) // Expose ref - useImperativeHandle(ref, () => graphRef.current) + useImperativeHandle(ref, () => graph) return } diff --git a/packages/webapp/src/features/graph/CirclesHTMLGraph.tsx b/packages/webapp/src/features/graph/CirclesHTMLGraph.tsx new file mode 100644 index 00000000..544386d5 --- /dev/null +++ b/packages/webapp/src/features/graph/CirclesHTMLGraph.tsx @@ -0,0 +1,60 @@ +import { Box } from '@chakra-ui/react' +import React, { forwardRef, useImperativeHandle, useRef } from 'react' +import { CirclesGraph } from './graphs/CirclesGraph' +import useCirclesGraph, { CirclesGraphProps } from './hooks/useCirclesGraph' +import CirclesTitles from './renderers/html/CirclesTitles' +import Nodes from './renderers/html/Nodes' +import { Panzoom } from './renderers/html/Panzoom' + +// Force reset with fast refresh +// @refresh reset + +export default forwardRef( + function CirclesHTMLGraph(props, ref) { + const containerRef = useRef(null) + + // Instanciate graph + const graph = useCirclesGraph(containerRef, props) + + // Expose ref + useImperativeHandle(ref, () => graph) + + const focusWidth = + props.width - (props.focusCrop?.left || 0) - (props.focusCrop?.right || 0) + + const focusHeight = + props.height - + (props.focusCrop?.top || 0) - + (props.focusCrop?.bottom || 0) + + const graphMinSize = Math.min(focusWidth, focusHeight) + + // Click outside => unselect circle + const handleClickOutside = (event: React.MouseEvent) => { + if (event.target === containerRef.current) { + props.events?.onClickOutside?.() + } + } + + return ( + + {graph && ( + + + + + )} + + ) + } +) diff --git a/packages/webapp/src/features/graph/CirclesSVGGraph.tsx b/packages/webapp/src/features/graph/CirclesSVGGraph.tsx index 03191d89..72c635d4 100644 --- a/packages/webapp/src/features/graph/CirclesSVGGraph.tsx +++ b/packages/webapp/src/features/graph/CirclesSVGGraph.tsx @@ -1,8 +1,11 @@ import { useColorMode } from '@chakra-ui/react' -import React, { forwardRef, useImperativeHandle, useRef } from 'react' +import { nanoid } from 'nanoid' +import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react' import { CirclesGraph } from './graphs/CirclesGraph' import useCirclesGraph, { CirclesGraphProps } from './hooks/useCirclesGraph' +import useRenderer from './hooks/useRenderer' import { StyledSVG } from './renderers/svg/StyledSVG' +import { SVGRenderer } from './renderers/svg/SVGRenderer' // Force reset with fast refresh // @refresh reset @@ -11,15 +14,18 @@ export default forwardRef( function CirclesSVGGraph(props, ref) { const { colorMode } = useColorMode() const svgRef = useRef(null) + const id = useMemo(() => nanoid(), []) // Instanciate graph - const graphRef = useCirclesGraph(svgRef, props) + const graph = useCirclesGraph(svgRef, props) + useRenderer(graph, (graph) => new SVGRenderer(graph)) // Expose ref - useImperativeHandle(ref, () => graphRef.current) + useImperativeHandle(ref, () => graph) return ( { - public renderer: Renderer public origCircles: CircleFullFragment[] = [] constructor( - public element: RootElement, + element: RootElement, public params: GraphParams ) { super(element, params) - - // Instanciate renderer - const tagName = element.tagName.toLowerCase() - if (tagName === 'canvas') { - this.renderer = new CanvasRenderer(this) - } else if (tagName === 'svg') { - this.renderer = new SVGRenderer(this) - } else { - throw new Error( - `Graph: Element tag name must be "canvas" or "svg", got "${element.tagName}"` - ) - } } destroy() { - this.renderer.destroy() - // @ts-ignore this.element = undefined // @ts-ignore @@ -180,26 +162,33 @@ export abstract class CirclesGraph extends Graph { .sum((d) => d.value || 0) .sort(this.packSorting) - return d3 - .pack() - .radius(() => settings.memberValue) - .padding((d) => { - // Circle - if (d.data.type === NodeType.Circle) { - const hasSubCircles = d.data.children?.some( - (c) => c.type === NodeType.Circle - ) - if (!hasSubCircles) return settings.padding.circleWithoutSubCircle - const multipleChildren = (d.data.children?.length || 0) > 1 - return multipleChildren - ? settings.padding.circleWithSubCircles - : settings.padding.circleWithSingleSubCircle - } else if (d.data.type === NodeType.MembersCircle) { - // Members Circle - return settings.padding.membersCircle - } - return 0 - })(hierarchyNode) + return ( + d3 + .pack() + .radius(() => settings.memberValue) + .padding((d) => { + // Circle + if (d.data.type === NodeType.Circle) { + const hasSubCircles = d.data.children?.some( + (c) => c.type === NodeType.Circle + ) + if (!hasSubCircles) return settings.padding.circleWithoutSubCircle + const multipleChildren = (d.data.children?.length || 0) > 1 + return multipleChildren + ? settings.padding.circleWithSubCircles + : settings.padding.circleWithSingleSubCircle + } else if (d.data.type === NodeType.MembersCircle) { + // Members Circle + return settings.padding.membersCircle + } + return 0 + })(hierarchyNode) + + // Sort by depth and Y, then raise + .sort((a, b) => + a.depth === b.depth ? a.y - b.y : a.depth < b.depth ? -1 : 1 + ) + ) } updateData(circles: CircleFullFragment[]) { @@ -235,6 +224,8 @@ export abstract class CirclesGraph extends Graph { ) } - this.emit('nodesData', nodesMap.slice(1)) + // Save and dispatch nodes data + this.nodes = nodesMap.slice(1) + this.emit('nodesData', this.nodes) } } diff --git a/packages/webapp/src/features/graph/graphs/Graph.ts b/packages/webapp/src/features/graph/graphs/Graph.ts index 1c008182..f80b2677 100644 --- a/packages/webapp/src/features/graph/graphs/Graph.ts +++ b/packages/webapp/src/features/graph/graphs/Graph.ts @@ -23,17 +23,26 @@ const defaultFocusCrop: Position = { const defaultFocusCircleScale: ZoomFocusCircleScale = (node) => Math.max(200, node.r * 1.05) -export abstract class Graph extends EventEmitter { +export type GraphEmitterEvents = { + zoomPosition: [ZoomTransform] + zoomScale: [number] + zoom: [ZoomTransform] + resize: void + nodesData: [NodeData[]] + selectCircle: [string | undefined] +} + +export abstract class Graph< + InputData = any, +> extends EventEmitter { public d3Root: d3.Selection public zoomDisabled = false public inputData: InputData | undefined - - protected selectedCircleId?: string + public nodes: NodeData[] = [] + public selectedCircleId?: string public width: number public height: number - public zoomX = 0 - public zoomY = 0 - public zoomScale = 1 + public zoomTransform = new ZoomTransform(1, 0, 0) protected zoomBehaviour: d3.ZoomBehavior protected focusCircleScale: ZoomFocusCircleScale public focusCrop: Position @@ -60,7 +69,7 @@ export abstract class Graph extends EventEmitter { this.rootRadius = 0 // D3 root selection - this.d3Root = d3.select(this.element) + this.d3Root = d3.select(element) // Zoom this.zoomBehaviour = d3 @@ -70,16 +79,14 @@ export abstract class Graph extends EventEmitter { .on('zoom', (event) => { if (this.unmounted) return const hasMoved = - this.zoomX !== event.transform.x || this.zoomY !== event.transform.y - const hasScaled = this.zoomScale !== event.transform.k - + this.zoomTransform.x !== event.transform.x || + this.zoomTransform.y !== event.transform.y + const hasScaled = this.zoomTransform.k !== event.transform.k + this.zoomTransform = event.transform if (hasMoved) { - this.zoomX = event.transform.x - this.zoomY = event.transform.y this.emit('zoomPosition', event.transform) } if (hasScaled) { - this.zoomScale = event.transform.k this.emit('zoomScale', event.transform.k) this.updatePanExtentDebounced() } @@ -93,12 +100,6 @@ export abstract class Graph extends EventEmitter { destroy() { this.unmounted = true this.d3Root.on('.zoom', null) - // @ts-ignore - this.d3Root = undefined - // @ts-ignore - this.zoomBehaviour = undefined - // @ts-ignore - this.focusCircleScale = undefined // Remove listeners this.removeAllListeners() } @@ -109,6 +110,7 @@ export abstract class Graph extends EventEmitter { selectCircle(id: string | undefined) { this.selectedCircleId = id + this.emit('selectCircle', id) if (id) { // Let draw first, then focus on circle setTimeout(() => this.focusNodeId(id, true), 100) @@ -122,34 +124,35 @@ export abstract class Graph extends EventEmitter { // Change extent to which we can pan updatePanExtent() { - const { width, height, rootRadius, zoomScale, focusCrop } = this + const { + width, + height, + rootRadius, + zoomTransform: { k }, + focusCrop, + } = this const extentX = - rootRadius * 2 * zoomScale < width / 2 - ? width / zoomScale - rootRadius - : width / zoomScale / 2 + rootRadius + rootRadius * 2 * k < width / 2 + ? width / k - rootRadius + : width / k / 2 + rootRadius const extentY = - rootRadius * 2 * zoomScale < height / 2 - ? height / zoomScale - rootRadius - : height / zoomScale / 2 + rootRadius - - this.zoomBehaviour.translateExtent([ - [ - -extentX + focusCrop.right / zoomScale, - -extentY + focusCrop.bottom / zoomScale, - ], - [ - extentX - focusCrop.left / zoomScale, - extentY - focusCrop.top / zoomScale, - ], + rootRadius * 2 * k < height / 2 + ? height / k - rootRadius + : height / k / 2 + rootRadius + + this.zoomBehaviour?.translateExtent([ + [-extentX + focusCrop.right / k, -extentY + focusCrop.bottom / k], + [extentX - focusCrop.left / k, extentY - focusCrop.top / k], ]) } updatePanExtentDebounced = debounce(this.updatePanExtent, 50) getDragEventPosition(event: d3.D3DragEvent) { + const { x, y, k } = this.zoomTransform return { - x: (event.sourceEvent.offsetX - this.zoomX) / this.zoomScale, - y: (event.sourceEvent.offsetY - this.zoomY) / this.zoomScale, + x: (event.sourceEvent.offsetX - x) / k, + y: (event.sourceEvent.offsetY - y) / k, } } @@ -164,7 +167,7 @@ export abstract class Graph extends EventEmitter { ) / (radius * 2) ) - : this.zoomScale + : this.zoomTransform.k // Prevent from zooming to an intermediate state where opacity of members is too low if (scale > 0.8 && scale < 1) { @@ -205,10 +208,10 @@ export abstract class Graph extends EventEmitter { const transform = new ZoomTransform( // Change scale to keep framing - this.zoomScale * scaleRatio, + this.zoomTransform.k * scaleRatio, // Reposition - (this.zoomX - this.focusOffsetX) * scaleRatio + focusOffsetX, - (this.zoomY - this.focusOffsetY) * scaleRatio + focusOffsetY + (this.zoomTransform.x - this.focusOffsetX) * scaleRatio + focusOffsetX, + (this.zoomTransform.y - this.focusOffsetY) * scaleRatio + focusOffsetY ) this.width = width @@ -239,19 +242,15 @@ export abstract class Graph extends EventEmitter { // Zoom on a node focusNodeId(nodeId?: string, adaptScale?: boolean, instant?: boolean) { - // Get descendants nodes data from svg - const nodesMap = this.d3Root - .selectAll('.circle') - .data() - if (nodesMap.length === 0) return + if (!this.nodes || this.nodes.length === 0) return const node = nodeId ? // Find node by id - nodesMap.find((n) => n.data.id === nodeId) + this.nodes.find((n) => n.data.id === nodeId) : // Find biggest node - nodesMap.reduce( + this.nodes.reduce( (n, biggest) => (n.r > biggest.r ? n : biggest), - nodesMap[0] + this.nodes[0] ) if (!node) return diff --git a/packages/webapp/src/features/graph/hooks/useGraph.tsx b/packages/webapp/src/features/graph/hooks/useGraph.tsx index dd987ef1..54a411c3 100644 --- a/packages/webapp/src/features/graph/hooks/useGraph.tsx +++ b/packages/webapp/src/features/graph/hooks/useGraph.tsx @@ -38,31 +38,29 @@ export default function useGraph>({ const { colorMode } = useColorMode() // Viz + const [graph, setGraph] = useState() const graphRef = useRef() - const [ready, setReady] = useState(false) - // Display viz and update data + // Instanciate graph useEffect(() => { - // Init Graph - if (!graphRef.current) { - const params = { - width, - height, - colorMode, - focusCrop, - focusCircleScale, - events: events || {}, - } - const graph = init(params) - - // Change ready state after first draw - graph.once('nodesData', () => setReady(true)) - graphRef.current = graph - graphContext?.setGraph(graph) + const params = { + width, + height, + colorMode, + focusCrop, + focusCircleScale, + events: events || {}, } + const graph = init(params) + graphRef.current = graph + setGraph(graph) + graphContext?.setGraph(graph) + onReady?.() + }, []) - // (Re)-draw graph - graphRef.current.updateData(data) + // Update data + useEffect(() => { + graphRef.current?.updateData(data) }, [data]) // Update dimensions @@ -81,26 +79,23 @@ export default function useGraph>({ // Unmount useEffect( () => () => { - // Unmount graph graphRef.current?.destroy() graphContext?.setGraph(undefined) }, [] ) - // Focus on a circle when focusCircleId is defined + // Re-apply data after graph is instanciated useEffect(() => { - if (ready) { - graphRef.current?.selectCircle(selectedCircleId) + if (graph?.inputData) { + graph.updateData(graph.inputData) } - }, [ready, selectedCircleId]) + }, [graph]) - // Call prop onReady when ready + // Focus on a circle useEffect(() => { - if (ready) { - onReady?.() - } - }, [ready]) + graphRef.current?.selectCircle(selectedCircleId) + }, [selectedCircleId]) - return graphRef + return graph } diff --git a/packages/webapp/src/features/graph/hooks/useRenderer.tsx b/packages/webapp/src/features/graph/hooks/useRenderer.tsx new file mode 100644 index 00000000..5dd17b87 --- /dev/null +++ b/packages/webapp/src/features/graph/hooks/useRenderer.tsx @@ -0,0 +1,18 @@ +import { useEffect, useMemo } from 'react' +import { Graph } from '../graphs/Graph' +import Renderer from '../renderers/Renderer' + +export default function useRenderer( + graph: Graph | undefined, + instanciate: (graph: Graph) => Renderer +) { + const renderer = useMemo(() => graph && instanciate(graph), [graph]) + + // Destroy renderer on unmount + useEffect( + () => () => { + renderer?.destroy() + }, + [renderer] + ) +} diff --git a/packages/webapp/src/features/graph/renderers/Renderer.ts b/packages/webapp/src/features/graph/renderers/Renderer.ts index d41e414e..5708212b 100644 --- a/packages/webapp/src/features/graph/renderers/Renderer.ts +++ b/packages/webapp/src/features/graph/renderers/Renderer.ts @@ -1,7 +1,7 @@ -import { CirclesGraph } from '../graphs/CirclesGraph' +import { Graph } from '../graphs/Graph' export default abstract class Renderer { - constructor(public graph: CirclesGraph) {} + constructor(public graph: Graph) {} destroy() { // @ts-ignore diff --git a/packages/webapp/src/features/graph/renderers/canvas/CanvasRenderer.ts b/packages/webapp/src/features/graph/renderers/canvas/CanvasRenderer.ts index 9a9a3adc..9b6d1493 100644 --- a/packages/webapp/src/features/graph/renderers/canvas/CanvasRenderer.ts +++ b/packages/webapp/src/features/graph/renderers/canvas/CanvasRenderer.ts @@ -3,7 +3,7 @@ import { Actions } from 'pixi-actions' import { Simple as Culling } from 'pixi-cull' import { addStats } from 'pixi-stats' import * as PIXI from 'pixi.js' -import { CirclesGraph } from '../../graphs/CirclesGraph' +import { Graph } from '../../graphs/Graph' import { NodeData, NodeType } from '../../types' import Renderer from '../Renderer' import { CircleObject } from './nodes/CircleObject' @@ -24,7 +24,7 @@ export class CanvasRenderer extends Renderer { public avatarsTextures = new Map() private circleTexture: PIXI.RenderTexture | undefined - constructor(public graph: CirclesGraph) { + constructor(public graph: Graph) { super(graph) const { element, width, height, params } = graph @@ -84,12 +84,17 @@ export class CanvasRenderer extends Renderer { private onZoom = () => { // Update culling - const { zoomX, zoomY, focusCrop, zoomScale, width, height } = this.graph + const { + zoomTransform: { x, y, k }, + focusCrop, + width, + height, + } = this.graph this.culling.cull({ - x: (-zoomX + focusCrop.left) / zoomScale, - y: (-zoomY + focusCrop.top) / zoomScale, - width: (width - focusCrop.left - focusCrop.right) / zoomScale, - height: (height - focusCrop.top - focusCrop.bottom) / zoomScale, + x: (-x + focusCrop.left) / k, + y: (-y + focusCrop.top) / k, + width: (width - focusCrop.left - focusCrop.right) / k, + height: (height - focusCrop.top - focusCrop.bottom) / k, }) } diff --git a/packages/webapp/src/features/graph/renderers/canvas/nodes/NodeObject.ts b/packages/webapp/src/features/graph/renderers/canvas/nodes/NodeObject.ts index 6c9142c4..f79e8a6c 100644 --- a/packages/webapp/src/features/graph/renderers/canvas/nodes/NodeObject.ts +++ b/packages/webapp/src/features/graph/renderers/canvas/nodes/NodeObject.ts @@ -152,7 +152,10 @@ export abstract class NodeObject { protected onPointerOver = () => { if (this.borderShape) return this.borderShape = new PIXI.Graphics() - .lineStyle(hoverBorderWidth / this.graph.zoomScale, this.getBorderColor()) + .lineStyle( + hoverBorderWidth / this.graph.zoomTransform.k, + this.getBorderColor() + ) .drawCircle(0, 0, this.d.r) this.container.addChild(this.borderShape) } diff --git a/packages/webapp/src/features/graph/renderers/html/CirclesTitles.tsx b/packages/webapp/src/features/graph/renderers/html/CirclesTitles.tsx new file mode 100644 index 00000000..6588783c --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/CirclesTitles.tsx @@ -0,0 +1,19 @@ +import { NodeType } from '@/graph/types' +import React, { memo } from 'react' +import { CirclesGraph } from '../../graphs/CirclesGraph' +import { useGraphNodes } from './hooks/useGraphNodes' +import CircleTitleElement from './nodes/CircleTitleElement' + +interface Props { + graph: CirclesGraph +} + +export default memo(function CirclesTitles({ graph }: Props) { + const nodes = useGraphNodes(graph) + + return nodes.map((node) => + node.data.type === NodeType.Circle ? ( + + ) : null + ) +}) diff --git a/packages/webapp/src/features/graph/renderers/html/Nodes.tsx b/packages/webapp/src/features/graph/renderers/html/Nodes.tsx new file mode 100644 index 00000000..e550bbc2 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/Nodes.tsx @@ -0,0 +1,35 @@ +import { NodeType } from '@/graph/types' +import React, { memo } from 'react' +import { CirclesGraph } from '../../graphs/CirclesGraph' +import { useGraphNodes } from './hooks/useGraphNodes' +import { useGraphSelectedCircleId } from './hooks/useGraphSelectedCircleId' +import CircleElement from './nodes/CircleElement' +import MemberElement from './nodes/MemberElement' + +interface Props { + graph: CirclesGraph +} + +export default memo(function Nodes({ graph }: Props) { + const nodes = useGraphNodes(graph) + const selectedCircleId = useGraphSelectedCircleId(graph) + + return nodes.map((node) => { + const selected = selectedCircleId === node.data.id + return node.data.type === NodeType.Circle ? ( + + ) : node.data.type === NodeType.Member ? ( + + ) : null + }) +}) diff --git a/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx b/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx new file mode 100644 index 00000000..7b15eb2e --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx @@ -0,0 +1,30 @@ +import { Box } from '@chakra-ui/react' +import React from 'react' +import { CirclesGraph } from '../../graphs/CirclesGraph' +import { useGraphZoom } from './hooks/useGraphZoom' + +interface Props { + graph: CirclesGraph + children: React.ReactNode +} + +export function Panzoom({ graph, children }: Props) { + const transform = useGraphZoom(graph) + + return ( + 1 ? undefined : 'none', + '--display-titles': transform.k > 1 ? 'none' : undefined, + } as React.CSSProperties + } + > + {children} + + ) +} diff --git a/packages/webapp/src/features/graph/renderers/html/animations.tsx b/packages/webapp/src/features/graph/renderers/html/animations.tsx new file mode 100644 index 00000000..4a174869 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/animations.tsx @@ -0,0 +1,16 @@ +import { keyframes } from '@chakra-ui/react' + +export const enterKeyframes = keyframes` + 0% { transform: scale(0); } + 100% { transform: scale(1);} +` + +export const leaveKeyframes = keyframes` + 0% { transform: scale(1); } + 100% { transform: scale(0);} +` + +const cubicInOut = 'cubic-bezier(0.645, 0.045, 0.355, 1)' + +export const enterAnimation = `${enterKeyframes} 0.2s ${cubicInOut}` +export const leaveAnimation = `${leaveKeyframes} 0.2s ${cubicInOut}` diff --git a/packages/webapp/src/features/graph/renderers/html/colors.tsx b/packages/webapp/src/features/graph/renderers/html/colors.tsx new file mode 100644 index 00000000..7c7dff5d --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/colors.tsx @@ -0,0 +1,26 @@ +import { + circleColor, + defaultCircleColorHue, +} from '@rolebase/shared/helpers/circleColor' + +const depthColorVariation = 5 + +export const getLightColor = ( + lightness: number, + depth = 1, + hue = defaultCircleColorHue +) => + circleColor( + `${lightness - (depth - 1) * depthColorVariation}%`, + hue.toString() + ) + +export const getDarkColor = ( + lightness: number, + depth = 1, + hue = defaultCircleColorHue +) => + circleColor( + `${lightness + (depth - 1) * depthColorVariation}%`, + hue.toString() + ) diff --git a/packages/webapp/src/features/graph/renderers/html/hooks/useGraphNodes.tsx b/packages/webapp/src/features/graph/renderers/html/hooks/useGraphNodes.tsx new file mode 100644 index 00000000..f0088072 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/hooks/useGraphNodes.tsx @@ -0,0 +1,15 @@ +import { CirclesGraph } from '@/graph/graphs/CirclesGraph' +import { useEffect, useState } from 'react' + +export function useGraphNodes(graph: CirclesGraph) { + const [nodes, setNodes] = useState(graph.nodes) + + useEffect(() => { + graph.on('nodesData', setNodes) + return () => { + graph.off('nodesData', setNodes) + } + }, [graph]) + + return nodes +} diff --git a/packages/webapp/src/features/graph/renderers/html/hooks/useGraphSelectedCircleId.tsx b/packages/webapp/src/features/graph/renderers/html/hooks/useGraphSelectedCircleId.tsx new file mode 100644 index 00000000..080db655 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/hooks/useGraphSelectedCircleId.tsx @@ -0,0 +1,17 @@ +import { CirclesGraph } from '@/graph/graphs/CirclesGraph' +import { useEffect, useState } from 'react' + +export function useGraphSelectedCircleId(graph: CirclesGraph) { + const [selectedCircleId, setSelectedCircleId] = useState( + graph.selectedCircleId + ) + + useEffect(() => { + graph.on('selectCircle', setSelectedCircleId) + return () => { + graph.off('selectCircle', setSelectedCircleId) + } + }, [graph]) + + return selectedCircleId +} diff --git a/packages/webapp/src/features/graph/renderers/html/hooks/useGraphZoom.tsx b/packages/webapp/src/features/graph/renderers/html/hooks/useGraphZoom.tsx new file mode 100644 index 00000000..72fb5280 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/hooks/useGraphZoom.tsx @@ -0,0 +1,15 @@ +import { CirclesGraph } from '@/graph/graphs/CirclesGraph' +import { useEffect, useState } from 'react' + +export function useGraphZoom(graph: CirclesGraph) { + const [transform, setTransform] = useState(graph.zoomTransform) + + useEffect(() => { + graph.on('zoom', setTransform) + return () => { + graph.off('zoom', setTransform) + } + }, [graph]) + + return transform +} diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx new file mode 100644 index 00000000..5b4dbd12 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx @@ -0,0 +1,53 @@ +import { CirclesGraph } from '@/graph/graphs/CirclesGraph' +import { NodeData } from '@/graph/types' +import { Text } from '@chakra-ui/react' +import React from 'react' +import CircleLeadersElement from './CircleLeadersElement' +import NodeElement from './NodeElement' + +interface Props { + graph: CirclesGraph + node: NodeData + selected: boolean +} + +const titleThreshold = 2 / 3 +const titleRate = 20 + +export default function CircleElement({ graph, node, selected }: Props) { + const { onCircleClick } = graph.params.events + + return ( + node.data.entityId && onCircleClick?.(node.data.entityId)} + > + + {node.data.name} + + + + ) +} diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/CircleLeadersElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/CircleLeadersElement.tsx new file mode 100644 index 00000000..1eb7086d --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/nodes/CircleLeadersElement.tsx @@ -0,0 +1,75 @@ +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' + +interface Props { + node: NodeData +} + +const radiusRatio = 0.4 +const padding2Ratio = 0.2 +const padding3Ratio = 0.03 + +export default function leadersElement({ node }: Props) { + if (!node.data.participants?.length) return null + + const depth = node.depth + 1 + const hue = node.data.colorHue + + // Get participants leaders + const leaders = useMemo( + () => + node.data.participants + ?.reduce((acc, p) => { + if (p.leader && !acc.find((p2) => p2.member.id === p.member.id)) { + acc.push(p) + } + return acc + }, [] as Participant[]) + .reverse(), + [node.data.participants] + ) + + const radius = node.r * radiusRatio + const padding = + node.r * ((leaders?.length || 0) > 2 ? padding3Ratio : padding2Ratio) + const xRange = 2 * (node.r - padding - radius) + + return ( + leaders?.map((leader, i) => { + const reverseI = leaders.length - 1 - i + const x = + !leaders || leaders.length === 1 + ? node.r - radius + : padding + (reverseI / (leaders.length - 1)) * xRange + + const y = node.r - radius + + return ( + + {!leader.member.picture && ( + + {leader.member.name[0].toUpperCase()} + + )} + + ) + }) || null + ) +} diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/CircleTitleElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/CircleTitleElement.tsx new file mode 100644 index 00000000..36b057a8 --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/nodes/CircleTitleElement.tsx @@ -0,0 +1,91 @@ +import { CirclesGraph } from '@/graph/graphs/CirclesGraph' +import { NodeData, NodeType } from '@/graph/types' +import { Text } from '@chakra-ui/react' +import React, { useEffect, useRef, useState } from 'react' + +interface Props { + graph: CirclesGraph + node: NodeData +} + +const gap = 0.01 +const rate = 20 +const threshold = 2 / 3 + +interface Size { + width: number + height: number +} + +export default function CircleTitleElement({ graph, node }: Props) { + 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(null) + const [size, setSize] = useState(undefined) + const sizeRatio = size ? (node.r * 2 * 0.9) / size.width : 1 + const finalWidth = sizeRatio * (size?.width || 0) + const finalHeight = sizeRatio * (size?.height || 0) + + useEffect(() => { + setSize(undefined) + }, [node.r]) + + useEffect(() => { + if (!size && textRef.current) { + setSize({ + width: textRef.current.offsetWidth, + height: textRef.current.offsetHeight, + }) + } + }, [size]) + + return ( + + {node.data.name} + + ) +} diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx new file mode 100644 index 00000000..61406d3a --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx @@ -0,0 +1,47 @@ +import { CirclesGraph } from '@/graph/graphs/CirclesGraph' +import { NodeData } from '@/graph/types' +import { Text } from '@chakra-ui/react' +import React, { useMemo } from 'react' +import NodeElement from './NodeElement' + +interface Props { + graph: CirclesGraph + node: NodeData + selected: boolean +} + +export default function MemberElement({ graph, node, selected }: Props) { + const { onMemberClick } = graph.params.events + + const hideFirstname = !!node.data.picture + const firstname = useMemo( + () => node.data.name.replace(/ .*$/, ''), + [node.data.name] + ) + + return ( + + node.data.parentId && + node.data.entityId && + onMemberClick?.(node.data.parentId, node.data.entityId) + } + > + + {firstname} + + + ) +} diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/NodeElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/NodeElement.tsx new file mode 100644 index 00000000..e4b5b54f --- /dev/null +++ b/packages/webapp/src/features/graph/renderers/html/nodes/NodeElement.tsx @@ -0,0 +1,65 @@ +import { NodeData, NodeType } from '@/graph/types' +import { BoxProps, Circle } from '@chakra-ui/react' +import React, { useEffect, useState } from 'react' +import { getDarkColor, getLightColor } from '../colors' + +interface Props extends BoxProps { + node: NodeData + selected?: boolean + children: React.ReactNode +} + +export default function NodeElement({ + node, + selected, + children, + ...boxProps +}: Props) { + 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) + }, []) + + return ( + + {children} + + ) +} diff --git a/packages/webapp/src/features/graph/renderers/svg/SVGRenderer.ts b/packages/webapp/src/features/graph/renderers/svg/SVGRenderer.ts index 34d0cf84..bee0e443 100644 --- a/packages/webapp/src/features/graph/renderers/svg/SVGRenderer.ts +++ b/packages/webapp/src/features/graph/renderers/svg/SVGRenderer.ts @@ -1,7 +1,7 @@ import { isSafari } from '@utils/env' import { Selection, ZoomTransform } from 'd3' import debounce from 'lodash.debounce' -import { CirclesGraph } from '../../graphs/CirclesGraph' +import { Graph } from '../../graphs/Graph' import { NodeData, NodeType } from '../../types' import Renderer from '../Renderer' import { AbstractCircleElement } from './elements/AbstractCircleElement' @@ -19,7 +19,7 @@ export class SVGRenderer extends Renderer { private circleElements: AbstractCircleElement[] - constructor(public graph: CirclesGraph) { + constructor(public graph: Graph) { super(graph) this.updateCSSVariables() @@ -67,7 +67,7 @@ export class SVGRenderer extends Renderer { this.graph.off('zoomScale', this.onZoomScale) document.body.removeEventListener('keydown', this.onKeyDown) document.body.removeEventListener('keyup', this.onKeyUp) - this.graph.element.removeEventListener('click', this.onClickOutside) + this.graph.element?.removeEventListener('click', this.onClickOutside) // @ts-ignore this.circleElements = undefined @@ -155,12 +155,6 @@ export class SVGRenderer extends Renderer { return nodeExit } ) - - // Sort by depth and Y, then raise - .sort((a, b) => - a.depth === b.depth ? a.y - b.y : a.depth < b.depth ? -1 : 1 - ) - .raise() } private drawCircleNames(nodesData: NodeData[]) { @@ -266,13 +260,16 @@ export class SVGRenderer extends Renderer { // Update CSS variables according to zoom updateCSSVariables() { - const { element, zoomScale } = this.graph - element.style.setProperty('--zoom-scale', zoomScale.toString()) + const { + element, + zoomTransform: { k }, + } = this.graph + element.style.setProperty('--zoom-scale', k.toString()) // Prevent from interacting with members when zoom < 1 element.style.setProperty( '--member-pointer-events', - zoomScale > 1 ? 'auto' : 'none' + k > 1 ? 'auto' : 'none' ) } diff --git a/packages/webapp/src/features/graph/settings.ts b/packages/webapp/src/features/graph/settings.ts index 32531e91..24e62367 100644 --- a/packages/webapp/src/features/graph/settings.ts +++ b/packages/webapp/src/features/graph/settings.ts @@ -23,12 +23,6 @@ export default { transition: d3.easeCubicInOut, duration: 500, }, - addMenu: { - marginTop: 56, - padding: 10, - spacing: 5, - placeholderRadius: 25, - }, style: { fontFamily: 'basier_circle', }, diff --git a/packages/webapp/src/features/graph/types.ts b/packages/webapp/src/features/graph/types.ts index 578fd6dd..a56ca714 100644 --- a/packages/webapp/src/features/graph/types.ts +++ b/packages/webapp/src/features/graph/types.ts @@ -19,7 +19,7 @@ export enum GraphRenderer { Canvas = 'Canvas', } -export type RootElement = HTMLCanvasElement | SVGSVGElement +export type RootElement = HTMLCanvasElement | SVGSVGElement | HTMLDivElement export enum NodeType { Circle = 'Circle',