Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FE] [refactor] graph web worker 적용 #133

Merged
merged 3 commits into from
Dec 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' });
}
});
};