Skip to content

Commit

Permalink
[FE] [refactor] graph web worker 적용 (#133)
Browse files Browse the repository at this point in the history
  • Loading branch information
yeynii committed Dec 13, 2022
1 parent d9670e7 commit 0d3b4d2
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 88 deletions.
Original file line number Diff line number Diff line change
@@ -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)();

Expand All @@ -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')
Expand All @@ -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;
13 changes: 7 additions & 6 deletions frontend/src/hooks/graph/useGraphData.ts
Original file line number Diff line number Diff line change
@@ -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<T>(data: IPaperDetail) {
const [links, setLinks] = useState<any[]>([]);
const nodes = useRef<any[]>([]);
export default function useGraphData(data: IPaperDetail) {
const [links, setLinks] = useState<Link[]>([]);
const nodes = useRef<Node[]>([]);
const doiMap = useRef<Map<string, number>>(new Map());

useEffect(() => {
Expand All @@ -27,7 +28,7 @@ export default function useGraphData<T>(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);
Expand All @@ -49,7 +50,7 @@ export default function useGraphData<T>(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 };
}
46 changes: 26 additions & 20 deletions frontend/src/hooks/graph/useGraphEmphasize.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
) {
Expand All @@ -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')
Expand All @@ -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]);
}
18 changes: 0 additions & 18 deletions frontend/src/hooks/graph/useLinkUpdate.ts

This file was deleted.

3 changes: 1 addition & 2 deletions frontend/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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';
70 changes: 43 additions & 27 deletions frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<SVGSVGElement | null>(null);
const linkRef = useRef<SVGGElement | null>(null);
const nodeRef = useRef<SVGGElement | null>(null);
const workerRef = useRef<Worker | null>(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 (
<Container>
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
});
};

0 comments on commit 0d3b4d2

Please sign in to comment.