From 5de7a6e7853ef9215c017f240b2f71a8a95061e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=83=D0=BB?= Date: Wed, 4 Dec 2024 19:27:05 +0300 Subject: [PATCH] feat: live update of 3d graph --- .../Universe/CursorTooltip/index.tsx | 89 +++++++++++++++ .../Graph/Cubes/RelevanceBadges/index.tsx | 2 +- .../Universe/Graph/Cubes/Text/index.tsx | 24 ++++- src/components/Universe/Graph/index.tsx | 6 +- .../mindset/components/Marker/index.tsx | 6 +- .../mindset/components/MediaPlayer/index.tsx | 2 - .../PlayerContols/ProgressBar/index.tsx | 8 +- .../components/PlayerContols/index.tsx | 2 +- src/components/mindset/index.tsx | 102 ++++++++++++++++-- src/stores/useSchemaStore/index.ts | 8 +- 10 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 src/components/Universe/CursorTooltip/index.tsx diff --git a/src/components/Universe/CursorTooltip/index.tsx b/src/components/Universe/CursorTooltip/index.tsx new file mode 100644 index 000000000..725896f8e --- /dev/null +++ b/src/components/Universe/CursorTooltip/index.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react' +import styled from 'styled-components' +import { Flex } from '~/components/common/Flex' +import { TypeBadge } from '~/components/common/TypeBadge' +import { useHoveredNode } from '~/stores/useGraphStore' +import { useSchemaStore } from '~/stores/useSchemaStore' +import { colors } from '~/utils' + +export const CursorTooltip = () => { + const tooltipRef = useRef(null) + + const node = useHoveredNode() + + const getIndexByType = useSchemaStore((s) => s.getIndexByType) + + const indexKey = node ? getIndexByType(node.node_type) : '' + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (tooltipRef.current) { + const tooltip = tooltipRef.current + const tooltipWidth = tooltip.offsetWidth + const tooltipHeight = tooltip.offsetHeight + + let top = e.clientY - 20 // 20px above the cursor + let left = e.clientX - 20 // 20px to the left of the cursor + + // Prevent clipping at the bottom of the screen + if (top + tooltipHeight > window.innerHeight) { + top = window.innerHeight - tooltipHeight - 10 // 10px padding + } + + // Prevent clipping on the right of the screen + if (left + tooltipWidth > window.innerWidth) { + left = window.innerWidth - tooltipWidth - 10 // 10px padding + } + + // Prevent clipping on the left of the screen + if (left < 0) { + left = 10 // Minimum padding + } + + // Prevent clipping at the top of the screen + if (top < 0) { + top = 10 // Minimum padding + } + + tooltip.style.top = `${top}px` + tooltip.style.left = `${left}px` + } + } + + window.addEventListener('mousemove', handleMouseMove) + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + } + }, []) + + // Ensure node exists before rendering tooltip + if (!node) { + return null + } + + const content = node.properties && indexKey && node.properties[indexKey] ? node.properties[indexKey] : '' + + return ( + + + + + {content} + + ) +} + +const TooltipContainer = styled(Flex)` + position: fixed; + background: ${colors.BG1}; + color: white; + padding: 5px; + border-radius: 3px; + pointer-events: none; /* Prevent interference with mouse events */ + z-index: 1000; /* Ensure it's on top */ + max-width: 200px; /* Optional: prevent overly large tooltips */ + white-space: nowrap; /* Optional: prevent text wrapping */ + overflow: hidden; /* Optional: prevent text overflow */ + text-overflow: ellipsis; /* Optional: add ellipsis for overflowing text */ +` diff --git a/src/components/Universe/Graph/Cubes/RelevanceBadges/index.tsx b/src/components/Universe/Graph/Cubes/RelevanceBadges/index.tsx index d052ff770..315c0009b 100644 --- a/src/components/Universe/Graph/Cubes/RelevanceBadges/index.tsx +++ b/src/components/Universe/Graph/Cubes/RelevanceBadges/index.tsx @@ -77,7 +77,7 @@ const NodeBadge = ({ position, userData, color }: BadgeProps) => {
- {truncateText(userData?.name, 20)} + {userData?.name ? {truncateText(userData?.name, 20)} : null} ) : ( { checkDistance() }) + useEffect(() => { + if (!ringRef.current) { + return + } + + gsap.fromTo( + ringRef.current.scale, // Target + { x: 1, y: 1, z: 1 }, // From values + { + x: 6, + y: 6, + z: 6, // To values + duration: 1.5, // Animation duration + yoyo: true, + repeat: 1, + }, + ) + }, [ringRef]) + const nodeTypes = useNodeTypes() const primaryColor = normalizedSchemasByType[node.node_type]?.primary_color @@ -143,7 +163,7 @@ export const TextNode = memo(({ node, hide, ignoreDistance }: Props) => { - {node.properties?.image_url && node.node_type === 'Person' && texture ? ( + {node.properties?.image_url && ['Person', 'Episode'].includes(node.node_type) && texture ? ( { const sphereRadius = Math.min(5000, boundingSphere.radius) - setGraphRadius(sphereRadius) - cameraSettled.current = true + if (false) { + setGraphRadius(sphereRadius) + cameraSettled.current = true + } } if (groupRef.current) { diff --git a/src/components/mindset/components/Marker/index.tsx b/src/components/mindset/components/Marker/index.tsx index 4b1d19c0c..2925249b8 100644 --- a/src/components/mindset/components/Marker/index.tsx +++ b/src/components/mindset/components/Marker/index.tsx @@ -38,11 +38,13 @@ export const Marker = memo(({ type, left, img }: Props) => { Marker.displayName = 'Marker' -const Badge = ({ iconStart, color, label }: BadgeProps) => ( +const Badge = memo(({ iconStart, color, label }: BadgeProps) => ( {iconStart && {label}} -) +)) + +Badge.displayName = 'Badge' const EpisodeWrapper = styled(Flex).attrs({ direction: 'row', diff --git a/src/components/mindset/components/MediaPlayer/index.tsx b/src/components/mindset/components/MediaPlayer/index.tsx index 58a8eacee..a073e4c97 100644 --- a/src/components/mindset/components/MediaPlayer/index.tsx +++ b/src/components/mindset/components/MediaPlayer/index.tsx @@ -131,7 +131,6 @@ const MediaPlayerComponent = ({ mediaUrl }: Props) => { const handleReady = () => { if (playerRef) { setStatus('ready') - togglePlay() } } @@ -156,7 +155,6 @@ const MediaPlayerComponent = ({ mediaUrl }: Props) => { setStatus('buffering')} onBufferEnd={() => setStatus('ready')} diff --git a/src/components/mindset/components/PlayerContols/ProgressBar/index.tsx b/src/components/mindset/components/PlayerContols/ProgressBar/index.tsx index 72d5814f2..557cb22da 100644 --- a/src/components/mindset/components/PlayerContols/ProgressBar/index.tsx +++ b/src/components/mindset/components/PlayerContols/ProgressBar/index.tsx @@ -13,11 +13,11 @@ type Props = { } export const ProgressBar = ({ duration, markers, handleProgressChange, playingTIme }: Props) => { - const thumbWidth = (10 / duration) * 100 + const width = (10 / duration) * 100 return ( - + {markers.map((node) => { const position = ((node?.start || 0) / duration) * 100 const type = node?.node_type || '' @@ -34,7 +34,7 @@ const ProgressWrapper = styled(Flex)` flex: 1 1 100%; ` -const ProgressSlider = styled(Slider)<{ thumbWidth: number }>` +const ProgressSlider = styled(Slider)<{ width: number }>` && { z-index: 20; color: ${colors.white}; @@ -45,7 +45,7 @@ const ProgressSlider = styled(Slider)<{ thumbWidth: number }>` border: none; } .MuiSlider-thumb { - width: ${({ thumbWidth }) => `${thumbWidth}%`}; + width: ${({ width }) => `${width}%`}; height: 54px; border-radius: 8px; background-color: ${colors.primaryBlue}; diff --git a/src/components/mindset/components/PlayerContols/index.tsx b/src/components/mindset/components/PlayerContols/index.tsx index d24561524..b14bc3b7f 100644 --- a/src/components/mindset/components/PlayerContols/index.tsx +++ b/src/components/mindset/components/PlayerContols/index.tsx @@ -37,7 +37,7 @@ export const PlayerControl = ({ markers }: Props) => { setCurrentTime(time) } - }, 100) + }, 500) return () => clearInterval(interval) }, [playerRef, setCurrentTime]) diff --git a/src/components/mindset/index.tsx b/src/components/mindset/index.tsx index c9c97cab9..fb007818f 100644 --- a/src/components/mindset/index.tsx +++ b/src/components/mindset/index.tsx @@ -8,7 +8,7 @@ import { getNode } from '~/network/fetchSourcesData' import { useDataStore } from '~/stores/useDataStore' import { useMindsetStore } from '~/stores/useMindsetStore' import { usePlayerStore } from '~/stores/usePlayerStore' -import { FetchDataResponse, NodeExtended } from '~/types' +import { FetchDataResponse, Link, Node, NodeExtended } from '~/types' import { Header } from './components/Header' import { LandingPage } from './components/LandingPage' import { PlayerControl } from './components/PlayerContols' @@ -16,10 +16,14 @@ import { Scene } from './components/Scene' import { SideBar } from './components/Sidebar' export const MindSet = () => { - const { addNewNode, isFetching, runningProjectId, dataInitial } = useDataStore((s) => s) - const [showTwoD, setShowTwoD] = useState(true) + const { addNewNode, isFetching, runningProjectId } = useDataStore((s) => s) + const [dataInitial, setDataInitial] = useState(null) + const [showTwoD, setShowTwoD] = useState(false) const { selectedEpisodeId, setSelectedEpisode } = useMindsetStore((s) => s) const socket: Socket | undefined = useSocket() + const requestRef = useRef(null) + const previousTimeRef = useRef(null) + const nodesAndEdgesRef = useRef(null) const queueRef = useRef(null) const timerRef = useRef(null) @@ -72,9 +76,42 @@ export const MindSet = () => { try { const data = await fetchNodeEdges(selectedEpisodeId, 0, 50) - if (data) { - handleNewNodeCreated(data) + setDataInitial(data) + + const [episodesAndClips, remainingNodes] = (data?.nodes || []).reduce<[Node[], Node[]]>( + ([matches, remaining], node) => { + if (['Episode', 'Show'].includes(node.node_type)) { + matches.push(node) + } else { + remaining.push(node) + } + + return [matches, remaining] + }, + [[], []], + ) + + const refIds = new Set(episodesAndClips.map((n) => n.ref_id)) + + const [matchingLinks, remainingLinks] = (data?.edges || []).reduce<[Link[], Link[]]>( + ([matches, remaining], link) => { + if (refIds.has(link.source) && refIds.has(link.target)) { + matches.push(link) + } else { + remaining.push(link) + } + + return [matches, remaining] + }, + [[], []], + ) + + nodesAndEdgesRef.current = { + nodes: remainingNodes || [], + edges: remainingLinks || [], } + + handleNewNodeCreated({ nodes: episodesAndClips, edges: matchingLinks }) } catch (error) { console.error(error) } @@ -112,8 +149,10 @@ export const MindSet = () => { console.error('Socket connection error:', error) }) - socket.on('new_node_created', handleNewNodeCreated) - socket.on('node_updated', handleNodeUpdated) + if (runningProjectId) { + socket.on('new_node_created', handleNewNodeCreated) + socket.on('node_updated', handleNodeUpdated) + } } return () => { @@ -121,7 +160,50 @@ export const MindSet = () => { socket.off() } } - }, [socket, handleNodeUpdated, handleNewNodeCreated]) + }, [socket, handleNodeUpdated, handleNewNodeCreated, runningProjectId]) + + useEffect(() => { + const update = (time: number) => { + const { playerRef } = usePlayerStore.getState() + + if (previousTimeRef.current !== null) { + const deltaTime = time - previousTimeRef.current + + if (deltaTime > 2000) { + if (nodesAndEdgesRef.current && playerRef) { + const { nodes, edges } = nodesAndEdgesRef.current + const currentTime = playerRef?.getCurrentTime() + + const edgesWithTimestamp = edges.filter( + (edge) => edge?.properties?.start !== undefined && (edge?.properties?.start as number) < currentTime, + ) + + const newNodes = nodes.filter((node) => + edgesWithTimestamp.some((edge) => edge.target === node.ref_id || edge.source === node.ref_id), + ) + + if (newNodes.length || edgesWithTimestamp.length) { + addNewNode({ nodes: newNodes, edges: edgesWithTimestamp }) + } + } + + previousTimeRef.current = time + } + } else { + previousTimeRef.current = time + } + + requestRef.current = requestAnimationFrame(update) + } + + requestRef.current = requestAnimationFrame(update) + + return () => { + if (requestRef.current) { + cancelAnimationFrame(requestRef.current) + } + } + }, [nodesAndEdgesRef, addNewNode]) useEffect(() => { if (runningProjectId) { @@ -135,12 +217,12 @@ export const MindSet = () => { const markers = useMemo(() => { if (dataInitial) { - const edgesMention: Array<{ source: string; target: string; start: number }> = dataInitial.links + const edgesMention: Array<{ source: string; target: string; start: number }> = dataInitial.edges .filter((e) => e?.properties?.start) .map((edge) => ({ source: edge.source, target: edge.target, start: edge.properties?.start as number })) const nodesWithTimestamps = dataInitial.nodes - .filter((node) => dataInitial.links.some((ed) => ed.source === node.ref_id || ed.target === node.ref_id)) + .filter((node) => dataInitial.edges.some((ed) => ed.source === node.ref_id || ed.target === node.ref_id)) .map((node) => { const edge = edgesMention.find((ed) => node.ref_id === ed.source || node.ref_id === ed.target) diff --git a/src/stores/useSchemaStore/index.ts b/src/stores/useSchemaStore/index.ts index 325900092..5bd29af73 100644 --- a/src/stores/useSchemaStore/index.ts +++ b/src/stores/useSchemaStore/index.ts @@ -11,12 +11,13 @@ type SchemasStore = { setSchemaLinks: (schema: SchemaLink[]) => void getPrimaryColorByType: (type: string) => string | undefined getNodeKeysByType: (type: string) => string | undefined + getIndexByType: (type: string) => string | undefined getSchemaByType: (type: string) => SchemaExtended | undefined } const defaultData: Omit< SchemasStore, - 'setSchemas' | 'setSchemaLinks' | 'getPrimaryColorByType' | 'getNodeKeysByType' | 'getSchemaByType' + 'setSchemas' | 'setSchemaLinks' | 'getPrimaryColorByType' | 'getNodeKeysByType' | 'getSchemaByType' | 'getIndexByType' > = { schemas: [], links: [], @@ -43,6 +44,11 @@ export const useSchemaStore = create()( return schema ? schema.primary_color : undefined }, + getIndexByType: (type: string) => { + const schema = get().normalizedSchemasByType[type] + + return schema ? schema.index : 'name' + }, getNodeKeysByType: (type: string) => { const schema = get().normalizedSchemasByType[type]