diff --git a/src/components/Universe/Controls/CameraAnimations/constants.ts b/src/components/Universe/Controls/CameraAnimations/constants.ts index b951381cc..1f4d11304 100644 --- a/src/components/Universe/Controls/CameraAnimations/constants.ts +++ b/src/components/Universe/Controls/CameraAnimations/constants.ts @@ -9,7 +9,7 @@ export const topicArriveDistance = 600 export const selectionGraphDistance = 2000 export const selectionGraphCameraPosition = { - x: 172.7392402058252, - y: -239.04675366094037, - z: -2000, + x: 0, + y: 0, + z: 200, } diff --git a/src/components/Universe/Graph/Connections/LineComponent.tsx b/src/components/Universe/Graph/Connections/LineComponent.tsx index b1f3dfef7..8c9b9177a 100644 --- a/src/components/Universe/Graph/Connections/LineComponent.tsx +++ b/src/components/Universe/Graph/Connections/LineComponent.tsx @@ -2,24 +2,28 @@ import { Billboard, Line, Text } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import gsap from 'gsap' import { memo, useEffect, useRef } from 'react' -import { Vector3 } from 'three' import { Line2 } from 'three-stdlib' import { useGraphStore } from '~/stores/useGraphStore' -import { LinkPosition } from '..' import { LINE_WIDTH } from '../../constants' type LineComponentProps = { - isSelected: boolean - position: LinkPosition label: string target: string source: string + sourceX: number + sourceY: number + sourceZ: number + targetX: number + targetY: number + targetZ: number } // eslint-disable-next-line no-underscore-dangle -const _LineComponent = ({ isSelected, position, label, target, source }: LineComponentProps) => { +const _LineComponent = (props: LineComponentProps) => { const lineRef = useRef(null) + const { label, source, target, sourceX, sourceY, sourceZ, targetX, targetY, targetZ } = props + useEffect(() => { if (lineRef.current) { const line = lineRef.current @@ -33,7 +37,7 @@ const _LineComponent = ({ isSelected, position, label, target, source }: LineCom }, ) } - }, [isSelected, lineRef]) + }, [lineRef]) useFrame(() => { const { selectedNode, hoveredNode } = useGraphStore.getState() @@ -69,16 +73,13 @@ const _LineComponent = ({ isSelected, position, label, target, source }: LineCom - {label} + {label}1 diff --git a/src/components/Universe/Graph/Connections/index.tsx b/src/components/Universe/Graph/Connections/index.tsx index d1b68aaff..27b386473 100644 --- a/src/components/Universe/Graph/Connections/index.tsx +++ b/src/components/Universe/Graph/Connections/index.tsx @@ -1,6 +1,6 @@ import { memo } from 'react' import { useDataStore } from '~/stores/useDataStore' -import { useGraphStore, useSelectedNode } from '~/stores/useGraphStore' +import { useGraphStore } from '~/stores/useGraphStore' import { Link } from '~/types' import { LinkPosition } from '..' import { LineComponent } from './LineComponent' @@ -12,13 +12,10 @@ type Props = { export const Connections = memo(({ linksPosition }: Props) => { const data = useDataStore((s) => s.dataInitial) const { showSelectionGraph } = useGraphStore((s) => s) - const selectedNode = useSelectedNode() return ( - + {data?.links.map((l: Link) => { - const isSelected = selectedNode?.ref_id === l.source || selectedNode?.ref_id === l.target // Adjust to match link with its position - const position = linksPosition.get(l.ref_id) || { sx: 0, sy: 0, @@ -31,11 +28,15 @@ export const Connections = memo(({ linksPosition }: Props) => { return ( ) })} diff --git a/src/components/Universe/Graph/Cubes/NodePoints/index.tsx b/src/components/Universe/Graph/Cubes/NodePoints/index.tsx index ddb0e35a0..6c9496160 100644 --- a/src/components/Universe/Graph/Cubes/NodePoints/index.tsx +++ b/src/components/Universe/Graph/Cubes/NodePoints/index.tsx @@ -52,7 +52,7 @@ const _NodePoints = () => { geometry={ringGeometry as BufferGeometry} limit={1000} // Optional: max amount of items (for calculating buffer size) range={1000} - visible={!selectedNode} + visible={!selectedNode || true} > {data?.nodes.map((node: NodeExtended) => { diff --git a/src/components/Universe/Graph/Cubes/NodeWrapper/index.tsx b/src/components/Universe/Graph/Cubes/NodeWrapper/index.tsx index 9364e0fb7..28465a967 100644 --- a/src/components/Universe/Graph/Cubes/NodeWrapper/index.tsx +++ b/src/components/Universe/Graph/Cubes/NodeWrapper/index.tsx @@ -11,13 +11,14 @@ type Props = { color: string scale: number index: number + stopFrames: boolean } const offset = { x: 20, y: 20 } export const NodeWrapper = memo( (props: Props) => { - const { node, color, index } = props + const { node, color, index, stopFrames } = props const simulation = useGraphStore((s) => s.simulation) const finishedSimulationCircle = useRef(false) @@ -25,6 +26,10 @@ export const NodeWrapper = memo( const wrapperRef = useRef(null) useFrame(({ camera, size }) => { + if (stopFrames) { + return + } + if (wrapperRef.current && simulation) { const simulationNode = simulation.nodes()[index] diff --git a/src/components/Universe/Graph/Cubes/SelectionDataNodes/Connections/Connection/index.tsx b/src/components/Universe/Graph/Cubes/SelectionDataNodes/Connections/Connection/index.tsx new file mode 100644 index 000000000..7372715c3 --- /dev/null +++ b/src/components/Universe/Graph/Cubes/SelectionDataNodes/Connections/Connection/index.tsx @@ -0,0 +1,42 @@ +import { Line, Text } from '@react-three/drei' +import { memo, useRef } from 'react' +import { Line2 } from 'three-stdlib' + +type LineComponentProps = { + label: string + sourceX: number + sourceY: number + sourceZ: number + targetX: number + targetY: number + targetZ: number +} + +// eslint-disable-next-line no-underscore-dangle +const _Connection = (props: LineComponentProps) => { + const lineRef = useRef(null) + + const { label, sourceX, sourceY, sourceZ, targetX, targetY, targetZ } = props + + return ( + + + + + {label} + + + + ) +} + +_Connection.displayName = 'Connection' + +export const Connection = memo(_Connection) diff --git a/src/components/Universe/Graph/Cubes/SelectionDataNodes/Connections/index.tsx b/src/components/Universe/Graph/Cubes/SelectionDataNodes/Connections/index.tsx new file mode 100644 index 000000000..a58248659 --- /dev/null +++ b/src/components/Universe/Graph/Cubes/SelectionDataNodes/Connections/index.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react' +import { useGraphStore } from '~/stores/useGraphStore' +import { Link } from '~/types' +import { LinkPosition } from '../../..' +import { Connection } from './Connection' + +type Props = { + linksPosition: Map +} + +export const Connections = memo(({ linksPosition }: Props) => { + const { selectionGraphData } = useGraphStore((s) => s) + + return ( + + {selectionGraphData?.links.map((l: Link) => { + const position = linksPosition.get(l.ref_id) || { + sx: 0, + sy: 0, + sz: 0, + tx: 0, + ty: 0, + tz: 0, + } + + return ( + + ) + })} + + ) +}) + +Connections.displayName = 'Connections' diff --git a/src/components/Universe/Graph/Cubes/SelectionDataNodes/Node/index.tsx b/src/components/Universe/Graph/Cubes/SelectionDataNodes/Node/index.tsx new file mode 100644 index 000000000..3f04d8abf --- /dev/null +++ b/src/components/Universe/Graph/Cubes/SelectionDataNodes/Node/index.tsx @@ -0,0 +1,115 @@ +import styled from 'styled-components' +import { Flex } from '~/components/common/Flex' +import { Icons } from '~/components/Icons' +import CloseIcon from '~/components/Icons/CloseIcon' +import NodesIcon from '~/components/Icons/NodesIcon' +import { useGraphStore } from '~/stores/useGraphStore' +import { useSchemaStore } from '~/stores/useSchemaStore' +import { NodeExtended } from '~/types' +import { colors } from '~/utils' +import { truncateText } from '~/utils/truncateText' + +type TagProps = { + rounded: boolean +} + +type Props = { + node: NodeExtended + rounded?: boolean + selected: boolean + onClick: () => void +} + +export const Node = ({ onClick, node, selected, rounded = true }: Props) => { + const { normalizedSchemasByType, getNodeKeysByType } = useSchemaStore((s) => s) + const setSelectedNode = useGraphStore((s) => s.setSelectedNode) + + const primaryIcon = normalizedSchemasByType[node.node_type]?.icon + + const Icon = primaryIcon ? Icons[primaryIcon] : null + // const iconName = Icon ? primaryIcon : 'NodesIcon' + const keyProperty = getNodeKeysByType(node.node_type) || '' + + const title = node?.properties ? node?.properties[keyProperty] : '' + const titleShortened = title ? truncateText(title, 30) : '' + + return ( + + <> + {selected ? ( + + setSelectedNode(null)}> + + +
{Icon ? : }
+ {titleShortened} +
+ ) : ( + <> + +
{Icon ? : }
+
+ {titleShortened} + + )} + +
+ ) +} + +const Wrapper = styled(Flex)`` + +const Text = styled(Flex)` + color: ${colors.white}; + margin-left: 16px; + font-weight: 700; +` + +const Tag = styled(Flex)` + text-align: center; + width: 48px; + height: 48px; + outline: 1px solid ${colors.white}; + outline-offset: 0px; + background: ${colors.BG1}; + color: ${colors.white}; + border-radius: ${(p: TagProps) => `${p.rounded ? '50%' : '6px'}`}; + cursor: pointer; + transition: font-size 0.4s, outline 0.4s; + align-items: center; + justify-content: center; + font-family: Barlow; + font-size: 24px; + font-style: normal; + font-weight: 700; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + + &:hover { + outline-offset: 4px; + } +` + +const Selected = styled(Tag)` + width: 300px; + height: 150px; +` + +const IconButton = styled(Flex)` + position: absolute; + top: -10px; + right: -10px; + width: 24px; + height: 24px; + + border-radius: 40px; + display: flex; + justify-content: center; + align-items: center; + background: black; + color: #ffffff; + border-radius: 100%; + font-size: 16px; + cursor: pointer; + transition: opacity 0.4s; + box-shadow: 0px 2px 12px rgba(0, 0, 0, 0.5); +` diff --git a/src/components/Universe/Graph/Cubes/SelectionDataNodes/index.tsx b/src/components/Universe/Graph/Cubes/SelectionDataNodes/index.tsx index a6199bfdb..4c4e693e2 100644 --- a/src/components/Universe/Graph/Cubes/SelectionDataNodes/index.tsx +++ b/src/components/Universe/Graph/Cubes/SelectionDataNodes/index.tsx @@ -1,16 +1,20 @@ -import { Segments } from '@react-three/drei' -import { forceCollide, forceLink, forceSimulation } from 'd3-force-3d' -import { memo, useEffect, useRef, useState } from 'react' -import { Group } from 'three' +import { Html } from '@react-three/drei' +import { forceLink, forceManyBody, forceRadial, forceSimulation } from 'd3-force-3d' +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { Box3, Color, Group, Sphere, Vector3 } from 'three' +import { Line2 } from 'three-stdlib' import { useShallow } from 'zustand/react/shallow' import { usePrevious } from '~/hooks/usePrevious' import { useDataStore } from '~/stores/useDataStore' import { useGraphStore, useSelectedNode, useSelectedNodeRelativeIds } from '~/stores/useGraphStore' +import { useSchemaStore } from '~/stores/useSchemaStore' import { ForceSimulation } from '~/transformers/forceSimulation' import { GraphData, Link, NodeExtended } from '~/types' -import { Segment } from '../../Segment' -import { PathwayBadges } from '../../Segment/LinkBadge' -import { TextNode } from '../Text' +import { LinkPosition } from '../..' +import { Connections } from './Connections' +import { Node } from './Node' + +const MAX_LENGTH = 6 export const SelectionDataNodes = memo(() => { const [simulation2d, setSimulation2D] = useState(null) @@ -19,17 +23,23 @@ export const SelectionDataNodes = memo(() => { const selectedNode = useSelectedNode() const groupRef = useRef(null) - const selectedNodeRelativeIds = useSelectedNodeRelativeIds() + const linksPositionRef = useRef(new Map()) + + const selectedNodeRelativeIds = useSelectedNodeRelativeIds().slice(0, MAX_LENGTH) - const prevNodesLength = usePrevious(dataInitial?.nodes.length) + const prevSelectedNodeId = usePrevious(selectedNode?.ref_id) - const { selectionGraphData, setSelectionData } = useGraphStore(useShallow((s) => s)) + const { normalizedSchemasByType } = useSchemaStore((s) => s) + + const { selectionGraphData, setSelectionData, setSelectedNode, setSelectionGraphRadius } = useGraphStore( + useShallow((s) => s), + ) useEffect(() => { const structuredNodes = structuredClone(dataInitial?.nodes || []) const structuredLinks = structuredClone(dataInitial?.links || []) - if (prevNodesLength === structuredNodes.length) { + if (prevSelectedNodeId === selectedNode?.ref_id) { return } @@ -51,8 +61,10 @@ export const SelectionDataNodes = memo(() => { ) setSelectionData({ nodes, links: links as unknown as GraphData['links'] }) + setSimulation2D(null) + linksPositionRef.current = new Map() } - }, [dataInitial, selectedNode, selectedNodeRelativeIds, setSelectionData, prevNodesLength]) + }, [dataInitial, selectedNode, selectedNodeRelativeIds, setSelectionData, prevSelectedNodeId]) useEffect(() => { if (simulation2d || !selectionGraphData.nodes.length) { @@ -69,15 +81,11 @@ export const SelectionDataNodes = memo(() => { 'link', forceLink() .links(structuredLinks) - .id((d: NodeExtended) => d.ref_id), - ) - .force( - 'collide', - forceCollide() - .radius(() => 150) - .strength(1) - .iterations(1), + .id((d: NodeExtended) => d.ref_id) + .distance(() => 150), ) + .force('radial', forceRadial(500, 0, 0, 0).strength(0)) + .force('charge', forceManyBody().strength(-1000)) .alpha(1) .restart() @@ -99,47 +107,102 @@ export const SelectionDataNodes = memo(() => { } simulation2d.on('tick', () => { - if (groupRef.current) { - const gr = groupRef.current as Group + if (!groupRef.current) { + return + } + + const gr = groupRef.current as Group + + gr.children.forEach((mesh, index) => { + const simulationNode = simulation2d.nodes()[index] + + if (simulationNode) { + mesh.position.set(simulationNode.x, simulationNode.y, simulationNode.z) + } + }) + + const grConnections = groupRef.current.getObjectByName('simulation-3d-group__connections') as Group + + grConnections.children.forEach((g, i) => { + const r = g.children[0] // Assuming Line is the first child + const text = g.children[1] // Assuming Text is the second child + + if (r instanceof Line2) { + const Line = r as Line2 + const link = selectionGraphData?.links[i] + + if (link) { + const sourceNode = simulation2d.nodes().find((n: NodeExtended) => n.ref_id === link.source) + const targetNode = simulation2d.nodes().find((n: NodeExtended) => n.ref_id === link.target) + + if (!sourceNode || !targetNode) { + return + } + + const { x: sx, y: sy, z: sz } = sourceNode + const { x: tx, y: ty, z: tz } = targetNode - gr.children.forEach((mesh, index) => { - const simulationNode = simulation2d.nodes()[index] + // Set positions for the link + linksPositionRef.current.set(link.ref_id, { + sx, + sy, + sz, + tx, + ty, + tz, + }) - if (simulationNode) { - mesh.position.set(simulationNode.x, simulationNode.y, simulationNode.z) + const midPoint = new Vector3((sx + tx) / 2, (sy + ty) / 2, (sz + tz) / 2) + + // Position the text + text.position.set(midPoint.x, midPoint.y, midPoint.z) + + // Set the line positions + Line.geometry.setPositions([sx, sy, sz, tx, ty, tz]) + + const { material } = Line + + material.color = new Color('white') } - }) - } + } + }) + }) + + simulation2d.on('end', () => { + const nodesVector = simulation2d.nodes().map((i: NodeExtended) => new Vector3(i.x, i.y, i.z)) + + const boundingBox = new Box3().setFromPoints(nodesVector) + + const boundingSphere = new Sphere() + + boundingBox.getBoundingSphere(boundingSphere) + + const sphereRadius = Math.min(5000, boundingSphere.radius) + + setSelectionGraphRadius(sphereRadius) }) - }, [simulation2d]) + }, [normalizedSchemasByType, selectionGraphData?.links, simulation2d, setSelectionGraphRadius]) + + const handleSelect = useCallback( + (node: NodeExtended) => { + setSelectedNode(node) + }, + [setSelectedNode], + ) return ( - <> - - {selectionGraphData?.nodes.map((node) => ( - - - - ))} - - - {(selectionGraphData?.links as unknown as GraphData['links']).map((link, index) => ( - - ))} - - {simulation2d && } - + + {selectionGraphData?.nodes.map((node) => ( + + + handleSelect(node)} selected={node.ref_id === selectedNode?.ref_id} /> + + + + + ))} + + ) }) diff --git a/src/components/Universe/Graph/Cubes/index.tsx b/src/components/Universe/Graph/Cubes/index.tsx index b2e117bd9..8cfb6bcd6 100644 --- a/src/components/Universe/Graph/Cubes/index.tsx +++ b/src/components/Universe/Graph/Cubes/index.tsx @@ -10,7 +10,6 @@ import { colors } from '~/utils' import { NodePoints } from './NodePoints' import { NodeWrapper } from './NodeWrapper' import { RelevanceBadges } from './RelevanceBadges' -import { SelectionDataNodes } from './SelectionDataNodes' const POINTER_IN_DELAY = 200 @@ -144,14 +143,22 @@ export const Cubes = memo(() => { {data?.nodes.map((node: NodeExtended, index: number) => { const color = COLORS_MAP[nodeTypes.indexOf(node.node_type)] || colors.white - return + return ( + + ) })} - {hideUniverse && } diff --git a/src/components/Universe/SelectionContent/Controls/index.tsx b/src/components/Universe/SelectionContent/Controls/index.tsx new file mode 100644 index 000000000..898eade77 --- /dev/null +++ b/src/components/Universe/SelectionContent/Controls/index.tsx @@ -0,0 +1,39 @@ +import { CameraControls } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { useGraphStore } from '~/stores/useGraphStore' +import { selectionGraphCameraPosition } from '../../Controls/CameraAnimations/constants' + +export const Controls = () => { + const cameraControlsRef = useRef(null) + const selectionGraphRadius = useGraphStore((s) => s.selectionGraphRadius) + + const [smoothTime] = useState(0.8) + + useEffect(() => { + if (cameraControlsRef.current) { + cameraControlsRef.current.setLookAt( + selectionGraphCameraPosition.x, + selectionGraphCameraPosition.y, + selectionGraphRadius * 2, + 0, + 0, + 0, + true, + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionGraphRadius]) + + return ( + + ) +} diff --git a/src/components/Universe/SelectionContent/index.tsx b/src/components/Universe/SelectionContent/index.tsx new file mode 100644 index 000000000..b8dd92d23 --- /dev/null +++ b/src/components/Universe/SelectionContent/index.tsx @@ -0,0 +1,13 @@ +import { SelectionDataNodes } from '../Graph/Cubes/SelectionDataNodes' +import { Lights } from '../Lights' +import { Controls } from './Controls' + +export const SelectionContent = () => ( + <> + + + + + + +) diff --git a/src/components/Universe/index.tsx b/src/components/Universe/index.tsx index 3a5ce713e..2226c8875 100644 --- a/src/components/Universe/index.tsx +++ b/src/components/Universe/index.tsx @@ -20,11 +20,12 @@ import { UniverseQuestion } from '../App/UniverseQuestion' import { Flex } from '../common/Flex' import { outlineEffectColor } from './constants' import { Controls } from './Controls' -import { initialCameraPosition } from './Controls/CameraAnimations/constants' +import { initialCameraPosition, selectionGraphCameraPosition } from './Controls/CameraAnimations/constants' import { Graph } from './Graph' import { Lights } from './Lights' import { Overlay } from './Overlay' import { Preloader } from './Preloader' +import { SelectionContent } from './SelectionContent' const Fallback = () => ( @@ -46,7 +47,7 @@ const Content = () => { return ( <> - + @@ -98,6 +99,7 @@ const _Universe = () => { const isLoading = useDataStore((s) => s.isFetching) const universeQuestionIsOpen = useAppStore((s) => s.universeQuestionIsOpen) + const selectedNode = useSelectedNode() const onWheelHandler = useCallback( (e: React.WheelEvent) => { @@ -132,18 +134,55 @@ const _Universe = () => { {universeQuestionIsOpen && } {isLoading && } @@ -157,4 +196,9 @@ const Wrapper = styled(Flex)` position: relative; ` +const SelectionWrapper = styled(Flex)` + position: absolute; + inset: 0; +` + export const Universe = memo(_Universe) diff --git a/src/stores/useGraphStore/index.ts b/src/stores/useGraphStore/index.ts index 82b165742..02c54d9c2 100644 --- a/src/stores/useGraphStore/index.ts +++ b/src/stores/useGraphStore/index.ts @@ -67,6 +67,7 @@ export const graphStyles: GraphStyle[] = ['sphere', 'force', 'split', 'earth'] export type GraphStore = { graphRadius: number + selectionGraphRadius: number data: { nodes: NodeExtended[]; links: Link[] } | null selectionGraphData: GraphData graphStyle: GraphStyle @@ -87,6 +88,7 @@ export type GraphStore = { setData: (data: GraphData) => void setGraphStyle: (graphStyle: GraphStyle) => void setGraphRadius: (graphRadius: number) => void + setSelectionGraphRadius: (graphRadius: number) => void setHoveredNode: (hoveredNode: NodeExtended | null) => void setSelectedNode: (selectedNode: NodeExtended | null) => void setActiveEdge: (edge: Link | null) => void @@ -110,6 +112,7 @@ const defaultData: Omit< | 'setActiveEdge' | 'setCameraFocusTrigger' | 'setGraphRadius' + | 'setSelectionGraphRadius' | 'setGraphStyle' | 'setNearbyNodeIds' | 'setShowSelectionGraph' @@ -125,6 +128,7 @@ const defaultData: Omit< disableCameraRotation: false, scrollEventsDisabled: false, graphRadius: 1500, // calculated from initial load + selectionGraphRadius: 200, // calculated from initial load graphStyle: 'sphere', hoveredNode: null, selectedNode: null, @@ -146,6 +150,7 @@ export const useGraphStore = create()((set, get) => ({ setDisableCameraRotation: (rotation) => set({ disableCameraRotation: rotation }), setIsHovering: (isHovering) => set({ isHovering }), setGraphRadius: (graphRadius) => set({ graphRadius }), + setSelectionGraphRadius: (selectionGraphRadius) => set({ selectionGraphRadius }), setGraphStyle: (graphStyle) => set({ graphStyle: 'sphere' || graphStyle }), setHoveredNode: (hoveredNode) => { set({ hoveredNode }) @@ -154,6 +159,15 @@ export const useGraphStore = create()((set, get) => ({ set({ activeEdge }) }, setSelectedNode: (selectedNode) => { + if (!selectedNode) { + set({ + hoveredNode: null, + selectedNode: null, + disableCameraRotation: false, + showSelectionGraph: false, + }) + } + const { selectedNode: stateSelectedNode, simulation } = get() if (stateSelectedNode?.ref_id !== selectedNode?.ref_id) { @@ -164,6 +178,7 @@ export const useGraphStore = create()((set, get) => ({ hoveredNode: null, selectedNode: selectedNodeWithCoordinates, disableCameraRotation: true, + showSelectionGraph: !!selectedNode, }) } },