diff --git a/frontend/src/hooks/graph/useNodeUpdate.ts b/frontend/src/hooks/graph/useGraph.ts similarity index 57% rename from frontend/src/hooks/graph/useNodeUpdate.ts rename to frontend/src/hooks/graph/useGraph.ts index f525058..a99903e 100644 --- a/frontend/src/hooks/graph/useNodeUpdate.ts +++ b/frontend/src/hooks/graph/useGraph.ts @@ -1,17 +1,33 @@ +import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph'; import theme from '@/style/theme'; import * as d3 from 'd3'; import { useCallback } from 'react'; -const NORMAL_SYMBOL_SIZE = 20; -const STAR_SYMBOL_SIZE = 100; - -export default function useNodeUpdate( - nodes: any[], - changeHoveredNode: (key: string) => void, +const useGraph = ( + nodeSelector: SVGGElement | null, + linkSelector: SVGGElement | null, addChildrensNodes: (doi: string) => void, -) { - return useCallback( - (nodesSelector: SVGGElement) => { + changeHoveredNode: (doi: string) => void, +) => { + const drawLink = useCallback( + (links: Link[]) => { + d3.select(linkSelector) + .selectAll('line') + .data(links) + .join('line') + .attr('x1', (d) => (d.source as Node).x || null) + .attr('y1', (d) => (d.source as Node).y || null) + .attr('x2', (d) => (d.target as Node).x || null) + .attr('y2', (d) => (d.target as Node).y || null); + }, + [linkSelector], + ); + + const drawNode = useCallback( + (nodes: Node[]) => { + const NORMAL_SYMBOL_SIZE = 20; + const STAR_SYMBOL_SIZE = 100; + const normalSymbol = d3.symbol().type(d3.symbolSquare).size(NORMAL_SYMBOL_SIZE)(); const starSymbol = d3.symbol().type(d3.symbolStar).size(STAR_SYMBOL_SIZE)(); @@ -20,7 +36,7 @@ export default function useNodeUpdate( return d3.scaleLinear([0, 4], ['white', theme.COLOR.secondary2]).interpolate(d3.interpolateRgb)(loged); }; - d3.select(nodesSelector) + d3.select(nodeSelector) .selectAll('path') .data(nodes) .join('path') @@ -32,18 +48,22 @@ export default function useNodeUpdate( .on('mouseout', () => changeHoveredNode('')) .on('click', (_, d) => d.doi && addChildrensNodes(d.doi)); - d3.select(nodesSelector) + d3.select(nodeSelector) .selectAll('text') .data(nodes) .join('text') .text((d) => `${d.author} ${d.publishedYear ? `(${d.publishedYear})` : ''}`) - .attr('x', (d) => d.x) - .attr('y', (d) => d.y + 10) + .attr('x', (d) => d.x || null) + .attr('y', (d) => (d.y ? d.y + 10 : null)) .attr('dy', 5) .on('mouseover', (_, d) => d.doi && changeHoveredNode(d.key)) .on('mouseout', () => changeHoveredNode('')) .on('click', (_, d) => d.doi && addChildrensNodes(d.doi)); }, - [nodes, addChildrensNodes, changeHoveredNode], + [nodeSelector, addChildrensNodes, changeHoveredNode], ); -} + + return { drawLink, drawNode }; +}; + +export default useGraph; diff --git a/frontend/src/hooks/graph/useGraphData.ts b/frontend/src/hooks/graph/useGraphData.ts index a2967ad..5e7de6b 100644 --- a/frontend/src/hooks/graph/useGraphData.ts +++ b/frontend/src/hooks/graph/useGraphData.ts @@ -1,9 +1,10 @@ import { IPaperDetail } from '@/api/api'; +import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph'; import { useEffect, useRef, useState } from 'react'; -export default function useGraphData(data: IPaperDetail) { - const [links, setLinks] = useState([]); - const nodes = useRef([]); +export default function useGraphData(data: IPaperDetail) { + const [links, setLinks] = useState([]); + const nodes = useRef([]); const doiMap = useRef>(new Map()); useEffect(() => { @@ -27,7 +28,7 @@ export default function useGraphData(data: IPaperDetail) { citations: v.citations, publishedYear: v.publishedAt && new Date(v.publishedAt).getFullYear(), })), - ]; + ] as Node[]; newNodes.forEach((node) => { const foundIndex = doiMap.current.get(node.key); @@ -49,7 +50,7 @@ export default function useGraphData(data: IPaperDetail) { target: reference.key.toLowerCase(), })); setLinks((prev) => [...prev, ...newLinks]); - }, [data]); + }, [data, links]); - return { nodes: nodes.current, links } as T; + return { nodes: nodes.current, links }; } diff --git a/frontend/src/hooks/graph/useGraphEmphasize.ts b/frontend/src/hooks/graph/useGraphEmphasize.ts index 9369051..7af82d4 100644 --- a/frontend/src/hooks/graph/useGraphEmphasize.ts +++ b/frontend/src/hooks/graph/useGraphEmphasize.ts @@ -1,6 +1,7 @@ -import { useTheme } from 'styled-components'; +import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph'; import * as d3 from 'd3'; -import { useEffect, useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; +import { useTheme } from 'styled-components'; const styles = { EMPHASIZE_OPACITY: '1', @@ -14,8 +15,8 @@ const styles = { export default function useGraphEmphasize( nodeSelector: SVGGElement | null, linkSelector: SVGGElement | null, - nodes: any[], - links: any[], + nodes: Node[], + links: Link[], hoveredNode: string, selectedKey: string, ) { @@ -40,14 +41,16 @@ export default function useGraphEmphasize( d3.select(nodeSelector) .selectAll('text') .data(nodes) - .filter((d) => - links - .filter((l) => l.source.key === hoveredNode) - .map((l) => l.target.key) - .includes(d.key), - ) + .filter((d) => { + return links + .filter((l) => l.source === hoveredNode) + .map((l) => l.target) + .includes(d.key); + }) .style('fill-opacity', styles.EMPHASIZE_OPACITY); + }, [hoveredNode, links, nodeSelector, nodes]); + useEffect(() => { // click된 노드 강조 d3.select(nodeSelector) .selectAll('text') @@ -59,27 +62,30 @@ export default function useGraphEmphasize( d3.select(nodeSelector) .selectAll('text') .data(nodes) - .filter((d) => - links - .filter((l) => l.source.key === selectedKey) - .map((l) => l.target.key) - .includes(d.key), - ) + .filter((d) => { + const result = links + .filter((l) => l.source === selectedKey) + .map((l) => l.target) + .includes(d.key); + return result; + }) .style('fill', theme.COLOR.secondary1); // click/hover된 노드의 링크 강조 d3.select(linkSelector) .selectAll('line') .data(links) - .style('stroke', (d) => getStyles(d.source.key, theme.COLOR.secondary1, theme.COLOR.gray1)) - .style('stroke-width', (d) => getStyles(d.source.key, styles.EMPHASIZE_STROKE_WIDTH, styles.BASIC_STROKE_WIDTH)) + .style('stroke', (d) => getStyles(d.source as string, theme.COLOR.secondary1, theme.COLOR.gray1)) + .style('stroke-width', (d) => + getStyles(d.source as string, styles.EMPHASIZE_STROKE_WIDTH, styles.BASIC_STROKE_WIDTH), + ) .style('stroke-dasharray', (d) => - getStyles(d.source.key, styles.EMPHASIZE_STROKE_DASH, styles.BASIC_STROKE_DASH), + getStyles(d.source as string, styles.EMPHASIZE_STROKE_DASH, styles.BASIC_STROKE_DASH), ); return () => { d3.select(nodeSelector).selectAll('text').style('fill-opacity', styles.BASIC_OPACITY); d3.select(nodeSelector).selectAll('text').style('fill', theme.COLOR.offWhite); }; - }, [nodeSelector, hoveredNode, nodes, links, selectedKey, linkSelector, getStyles, theme]); + }, [nodeSelector, nodes, links, selectedKey, linkSelector, getStyles, theme]); } diff --git a/frontend/src/hooks/graph/useLinkUpdate.ts b/frontend/src/hooks/graph/useLinkUpdate.ts deleted file mode 100644 index fa8d5e4..0000000 --- a/frontend/src/hooks/graph/useLinkUpdate.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as d3 from 'd3'; -import { useCallback } from 'react'; - -export default function useLinkUpdate(links: any[]) { - return useCallback( - (linksSelector: SVGGElement) => { - d3.select(linksSelector) - .selectAll('line') - .data(links) - .join('line') - .attr('x1', (d) => d.source?.x) - .attr('y1', (d) => d.source?.y) - .attr('x2', (d) => d.target?.x) - .attr('y2', (d) => d.target?.y); - }, - [links], - ); -} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d03db2c..850c245 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,7 +1,6 @@ +export { default as useGraph } from './graph/useGraph'; export { default as useGraphData } from './graph/useGraphData'; export { default as useGraphEmphasize } from './graph/useGraphEmphasize'; export { default as useGraphZoom } from './graph/useGraphZoom'; -export { default as useLinkUpdate } from './graph/useLinkUpdate'; -export { default as useNodeUpdate } from './graph/useNodeUpdate'; export { default as useDebouncedValue } from './useDebouncedValue'; export { default as useInterval } from './useInterval'; diff --git a/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx b/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx index 91ca762..f8b8353 100644 --- a/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx +++ b/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx @@ -1,6 +1,6 @@ import { IPaperDetail } from '@/api/api'; -import { useGraphData, useGraphEmphasize, useGraphZoom, useLinkUpdate, useNodeUpdate } from '@/hooks'; -import * as d3 from 'd3'; +import { useGraph, useGraphData, useGraphEmphasize, useGraphZoom } from '@/hooks'; +import { SimulationNodeDatum } from 'd3'; import { useEffect, useRef } from 'react'; import styled from 'styled-components'; import InfoTooltip from './InfoTooltip'; @@ -12,43 +12,59 @@ interface ReferenceGraphProps { changeHoveredNode: (key: string) => void; } -// Todo : any 걷어내기, 구조 리팩터링하기, 프론트 테스트 +export interface Node extends SimulationNodeDatum { + [key: string]: string | boolean | number | null | undefined; + title?: string; + author?: string; + isSelected: boolean; + key: string; + doi?: string; + citations?: number; + publishedYear?: number; +} + +export interface Link { + source: Node | string; + target: Node | string; +} + const ReferenceGraph = ({ data, addChildrensNodes, hoveredNode, changeHoveredNode }: ReferenceGraphProps) => { const svgRef = useRef(null); const linkRef = useRef(null); const nodeRef = useRef(null); + const workerRef = useRef(null); - const { nodes, links } = useGraphData<{ nodes: any[]; links: any[] }>(data); - - const updateLinks = useLinkUpdate(links); - const updateNodes = useNodeUpdate(nodes, changeHoveredNode, addChildrensNodes); + const { nodes, links } = useGraphData(data); + const { drawLink, drawNode } = useGraph(nodeRef.current, linkRef.current, addChildrensNodes, changeHoveredNode); useGraphZoom(svgRef.current); useGraphEmphasize(nodeRef.current, linkRef.current, nodes, links, hoveredNode, data.key); useEffect(() => { - const ticked = (linksSelector: SVGGElement, nodesSelector: SVGGElement) => { - updateLinks(linksSelector); - updateNodes(nodesSelector); - }; + if (links.length <= 0 || !svgRef.current) return; + + if (workerRef.current !== null) { + workerRef.current.terminate(); + } + + workerRef.current = new Worker(new URL('../workers/forceSimulation.worker.ts', import.meta.url)); + + // 서브스레드로 nodes, links, 중앙좌표 전송 + workerRef.current.postMessage({ + nodes, + links, + centerX: svgRef.current?.clientWidth / 2, + centerY: svgRef.current?.clientHeight / 2, + }); - const simulation = d3 - .forceSimulation(nodes) - .force('charge', d3.forceManyBody().strength(-200).distanceMax(200)) - .force( - 'center', - svgRef?.current && d3.forceCenter(svgRef.current.clientWidth / 2, svgRef.current.clientHeight / 2), - ) - .force( - 'link', - d3.forceLink(links).id((d: any) => d.key), - ) - .on('tick', () => linkRef.current && nodeRef.current && ticked(linkRef.current, nodeRef.current)); - - return () => { - simulation.stop(); + // 계산된 좌표를 포함한 nodes, links 수신 + workerRef.current.onmessage = (event) => { + const { newNodes, newLinks } = event.data as { newNodes: Node[]; newLinks: Link[] }; + if (!newLinks || newLinks.length <= 0) return; + drawLink(newLinks); + drawNode(newNodes); }; - }, [nodes, links, updateLinks, updateNodes]); + }, [nodes, links, drawLink, drawNode]); return ( diff --git a/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts b/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts new file mode 100644 index 0000000..86b3d90 --- /dev/null +++ b/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts @@ -0,0 +1,30 @@ +import * as d3 from 'd3'; +import { Link, Node } from '../components/ReferenceGraph'; + +interface DataProps { + data: { + nodes: Node[]; + links: Link[]; + centerX: number; + centerY: number; + }; +} + +self.onmessage = ({ data }: DataProps) => { + const { nodes, links, centerX, centerY } = data; + const simulation = d3 + .forceSimulation(nodes) + .force('charge', d3.forceManyBody().strength(-200).distanceMax(200)) + .force('center', d3.forceCenter(centerX, centerY)) + .force( + 'link', + d3.forceLink(links).id((d) => (d as Node).key), + ) + .on('tick', () => { + self.postMessage({ type: 'tick', newNodes: nodes, newLinks: links }); + if (simulation.alpha() < simulation.alphaMin()) { + simulation.stop(); + self.postMessage({ type: 'stop' }); + } + }); +};