diff --git a/README.md b/README.md index f6f60bb..184aa75 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,11 @@ ![Javascript](https://img.shields.io/badge/javascript-ES6+-yellow?logo=javascript) ![NodeJS](https://img.shields.io/badge/node.js-v18-green?logo=node.js) +![데모](https://user-images.githubusercontent.com/25934842/207359316-f7056911-d26a-4671-bc3c-2a80e46f24b8.gif) -### 서비스 배포 - -- Dev 서버 : http://49.50.172.204:3000/ - -- Production 서버 : http://101.101.217.49:3000/ +### 서비스 링크 : https://paperef.com ### 팀원 @@ -39,7 +36,6 @@ 최예윤 - ### 개발 환경 세팅 @@ -84,6 +80,7 @@ ELASTIC_USER= ELASTIC_PASSWORD= ALLOW_UPDATE= MAIL_TO= +SHOULD_RUN_BATCH= ``` ## 기술스택 @@ -100,7 +97,7 @@ MAIL_TO= - 키워드 자동완성 검색 서비스 제공 - 키워드 검색 서비스 제공 - 논문 DOI를 통한 인용관계 시각화 서비스 제공 -- 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보만 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다. +- 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보 혹은 Crossref API를 통해 요청한 정보를 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다. - Request batch에 의해 수집된 결과는 데이터베이스에 저장됩니다. - 추가 문의사항은 viewpoint.prv@gmail.com 로 연락바랍니다. diff --git a/backend/src/ranking/ranking.controller.ts b/backend/src/ranking/ranking.controller.ts index 16d7d3f..816155d 100644 --- a/backend/src/ranking/ranking.controller.ts +++ b/backend/src/ranking/ranking.controller.ts @@ -12,8 +12,4 @@ export class RankingController { async getTen() { return await this.rankingService.getTen(); } - @Get('/insert') - async insertCache(@Query('keyword') searchStr: string) { - return this.rankingService.insertRedis(searchStr); - } } diff --git a/backend/src/ranking/tests/ranking.controller.spec.ts b/backend/src/ranking/tests/ranking.controller.spec.ts index ee0fcc5..a329a4d 100644 --- a/backend/src/ranking/tests/ranking.controller.spec.ts +++ b/backend/src/ranking/tests/ranking.controller.spec.ts @@ -21,23 +21,23 @@ describe('RankingServiceTest', () => { describe('/keyword-ranking', () => { it('redis date가 10개 이하인 경우', async () => { //Case 1. redis date가 10개 이하인 경우 - const topTen = await controller.getTen(); + const topTen = await service.getTen(); expect(topTen.length).toBeLessThanOrEqual(10); }); it('데이터 삽입 후 topTen 체크', async () => { //Case 2. 데이터 삽입 후 topTen 체크 - const flag = await controller.insertCache('9번째 데이터'); + const flag = await service.insertRedis('9번째 데이터'); expect(flag).toBe('new'); const topTen = await controller.getTen(); expect(topTen.length).toBe(9); - const flag2 = await controller.insertCache('10번째 데이터'); + const flag2 = await service.insertRedis('10번째 데이터'); expect(flag2).toBe('new'); const topTen2 = await controller.getTen(); expect(topTen2.length).toBe(10); }); it('2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트', async () => { //Case 3. 2위인 "사랑해요" 데이터가 한번 더 검색시 1위로 업데이트 - const flag = await controller.insertCache('사랑해요'); + const flag = await service.insertRedis('사랑해요'); expect(flag).toBe('update'); const topTen = await controller.getTen(); expect(topTen[0].keyword).toBe('부스트캠프'); @@ -46,23 +46,23 @@ describe('RankingServiceTest', () => { describe('/keyword-ranking/insert', () => { // Case1. 기존 redis에 없던 데이터 삽입 it('기존 redis에 없던 데이터 삽입', async () => { - const result = await controller.insertCache('newData'); + const result = await service.insertRedis('newData'); expect(result).toBe('new'); }); // Case2. 기존 redis에 있던 데이터 삽입 it('기존 redis에 있던 데이터 삽입', async () => { - const result = await controller.insertCache('부스트캠프'); + const result = await service.insertRedis('부스트캠프'); expect(result).toBe('update'); }); //Case3. redis에 빈 검색어 입력 it('빈 검색어 redis에 삽입', async () => { - await expect(controller.insertCache('')).rejects.toEqual( + await expect(service.insertRedis('')).rejects.toEqual( new BadRequestException({ status: 400, error: 'bad request' }), ); }); //Case4. insert 실패시 타임 아웃 TimeOut it('insert 실패시 타임 아웃 TimeOut', async () => { - await expect(controller.insertCache('')).rejects.toEqual( + await expect(service.insertRedis('')).rejects.toEqual( new BadRequestException({ status: 400, error: 'bad request' }), ); }); diff --git a/backend/src/search/search.controller.ts b/backend/src/search/search.controller.ts index 2a2cf85..5d6ab75 100644 --- a/backend/src/search/search.controller.ts +++ b/backend/src/search/search.controller.ts @@ -46,6 +46,18 @@ export class SearchController { const keywordHasSet = await this.batchService.setKeyword(keyword); if (keywordHasSet) this.batchService.searchBatcher.pushToQueue(0, 0, -1, true, keyword); + // Elasticsearch 검색 결과가 없을 경우, Crossref 검색 + if (totalItems === 0) { + const { items: papers, totalItems } = await this.searchService.getPapersFromCrossref(keyword, rows, page); + return { + papers, + pageInfo: { + totalItems, + totalPages: Math.ceil(totalItems / rows), + }, + }; + } + const papers = data.hits.hits.map((paper) => new PaperInfoExtended(paper._source)); return { papers, diff --git a/backend/src/search/search.service.ts b/backend/src/search/search.service.ts index 007c227..b2e2b9a 100644 --- a/backend/src/search/search.service.ts +++ b/backend/src/search/search.service.ts @@ -1,15 +1,16 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, RequestTimeoutException } from '@nestjs/common'; import { CrossRefItem, PaperInfoExtended, PaperInfo, PaperInfoDetail, CrossRefPaperResponse, + CrossRefResponse, } from './entities/crossRef.entity'; import { ElasticsearchService } from '@nestjs/elasticsearch'; import { MgetOperation, SearchHit } from '@elastic/elasticsearch/lib/api/types'; import { HttpService } from '@nestjs/axios'; -import { CROSSREF_API_PAPER_URL } from '../util'; +import { CROSSREF_API_PAPER_URL, CROSSREF_API_URL } from '../util'; import { ELASTIC_INDEX } from 'src/envLayer'; @Injectable() @@ -75,6 +76,17 @@ export class SearchService { return new PaperInfoDetail(data); }; + async getPapersFromCrossref(keyword: string, rows: number, page: number, selects?: string[]) { + const crossRefdata = await this.httpService.axiosRef + .get(CROSSREF_API_URL(keyword, rows, page, selects)) + .catch((err) => { + throw new RequestTimeoutException(err.message); + }); + const items = crossRefdata.data.message.items.map((item) => this.parsePaperInfoExtended(item)); + const totalItems = crossRefdata.data.message['total-results']; + return { items, totalItems }; + } + async getPaperFromCrossref(doi: string) { try { const item = await this.httpService.axiosRef.get(CROSSREF_API_PAPER_URL(doi)); diff --git a/backend/src/util.ts b/backend/src/util.ts index 18ac270..d4a99fe 100644 --- a/backend/src/util.ts +++ b/backend/src/util.ts @@ -1,7 +1,12 @@ import { MAIL_TO } from './envLayer'; const BASE_URL = 'https://api.crossref.org/works'; -export const CROSSREF_API_URL = (keyword: string, rows = 5, page = 1, selects: string[] = ['author', 'title', 'DOI']) => +export const CROSSREF_API_URL = ( + keyword: string, + rows = 5, + page = 1, + selects: string[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI'], +) => `${BASE_URL}?query=${keyword}&rows=${rows}&select=${selects.join(',')}&offset=${rows * (page - 1)}&mailto=${MAIL_TO}`; export const MAX_ROWS = 1000; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 3e5c5a9..118b2f7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -23,5 +23,5 @@ it('Footer 렌더링 테스트', () => { ); }); const span = container?.querySelector('span'); - expect(span?.textContent).toBe('문의사항, 버그제보: vp.prv@gmail.com'); + expect(span?.textContent).toBe('문의사항, 버그제보: viewpoint.prv@gmail.com'); }); diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 72e20f6..90d3733 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -9,7 +9,7 @@ interface FooterProps { const Footer = ({ bgColor, contentColor }: FooterProps) => { return ( - 문의사항, 버그제보: vp.prv@gmail.com + 문의사항, 버그제보: viewpoint.prv@gmail.com { }; const Button = styled.button` + display: flex; + align-items: center; background-color: transparent; cursor: pointer; `; diff --git a/frontend/src/components/search/AutoCompletedList.tsx b/frontend/src/components/search/AutoCompletedList.tsx index e9fb8d3..02a4cbb 100644 --- a/frontend/src/components/search/AutoCompletedList.tsx +++ b/frontend/src/components/search/AutoCompletedList.tsx @@ -79,6 +79,7 @@ const AutoCompleted = styled.li<{ hovered: boolean }>` const Title = styled.div` ${({ theme }) => theme.TYPO.body1} + line-height: 1.1em; `; const Author = styled.div` diff --git a/frontend/src/components/search/RecentKeywordsList.tsx b/frontend/src/components/search/RecentKeywordsList.tsx index 2b83dc3..f981934 100644 --- a/frontend/src/components/search/RecentKeywordsList.tsx +++ b/frontend/src/components/search/RecentKeywordsList.tsx @@ -1,5 +1,6 @@ import { IconButton } from '@/components'; import { ClockIcon, XIcon } from '@/icons'; +import { Ellipsis } from '@/style/styleUtils'; import { setLocalStorage } from '@/utils/storage'; import { isEmpty } from 'lodash-es'; import { Dispatch, SetStateAction, useEffect } from 'react'; @@ -42,7 +43,7 @@ const RecentKeywordsList = ({ onMouseDown={() => handleMouseDown(keyword)} > - {keyword} + {keyword} } onMouseDown={(e) => handleRecentKeywordRemove(e, keyword)} @@ -71,6 +72,11 @@ const Keyword = styled.li<{ hovered: boolean }>` background-color: ${({ theme, hovered }) => (hovered ? theme.COLOR.gray1 : 'auto')}; `; +const KeywordText = styled(Ellipsis)` + width: 100%; + display: block; +`; + const NoResult = styled.div` padding-top: 25px; text-align: center; 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..6c3ffda 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,23 @@ 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) + .style('font-weight', 700) .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..b9ef96f 100644 --- a/frontend/src/hooks/graph/useGraphEmphasize.ts +++ b/frontend/src/hooks/graph/useGraphEmphasize.ts @@ -1,11 +1,12 @@ -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', BASIC_OPACITY: '0.5', - EMPHASIZE_STROKE_WIDTH: '0.8px', + EMPHASIZE_STROKE_WIDTH: '1.5px', BASIC_STROKE_WIDTH: '0.5px', EMPHASIZE_STROKE_DASH: 'none', BASIC_STROKE_DASH: '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,46 +41,53 @@ 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, theme]); + + useEffect(() => { + if (nodeSelector === null) return; // click된 노드 강조 d3.select(nodeSelector) .selectAll('text') .data(nodes) .filter((d) => d.key === selectedKey) - .style('fill', theme.COLOR.secondary1); + .style('fill', theme.COLOR.secondary2); // click된 노드의 자식 노드들 강조 d3.select(nodeSelector) .selectAll('text') .data(nodes) - .filter((d) => - links - .filter((l) => l.source.key === selectedKey) - .map((l) => l.target.key) - .includes(d.key), - ) - .style('fill', theme.COLOR.secondary1); + .filter((d) => { + const result = links + .filter((l) => l.source === selectedKey) + .map((l) => l.target) + .includes(d.key); + return result; + }) + .style('fill', theme.COLOR.secondary2); // 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/icons/InfoIcon.tsx b/frontend/src/icons/InfoIcon.tsx index 1799cfd..87d1259 100644 --- a/frontend/src/icons/InfoIcon.tsx +++ b/frontend/src/icons/InfoIcon.tsx @@ -1,9 +1,13 @@ -const InfoIcon = () => { +interface IProps { + color: string; +} + +const InfoIcon = ({ color }: IProps) => { return ( diff --git a/frontend/src/pages/Main/components/KeywordRanking.tsx b/frontend/src/pages/Main/components/KeywordRanking.tsx index 9dadfcf..7aa1d10 100644 --- a/frontend/src/pages/Main/components/KeywordRanking.tsx +++ b/frontend/src/pages/Main/components/KeywordRanking.tsx @@ -1,6 +1,7 @@ import { IconButton } from '@/components'; import { DropdownIcon, DropdownReverseIcon } from '@/icons'; import { useKeywordRankingQuery } from '@/queries/queries'; +import { Ellipsis } from '@/style/styleUtils'; import { createSearchQuery } from '@/utils/createQueryString'; import { useState } from 'react'; import { Link } from 'react-router-dom'; @@ -19,7 +20,7 @@ const KeywordRanking = () => { - 인기 검색어 + 인기 검색어 {!isLoading && rankingData?.length ? : '데이터가 없습니다.'} @@ -71,29 +72,30 @@ const RankingBar = styled.div` z-index: 10; `; -const RankingContent = styled.div` - display: flex; - flex-grow: 1; - align-items: center; - margin: 0 10px; - height: 25px; - cursor: pointer; -`; - const HeaderContainer = styled.div` display: flex; - justify-content: space-between; align-items: center; - position: relative; - height: 23px; width: 100%; + height: 23px; ${({ theme }) => theme.TYPO.body_h} `; +const Title = styled.span` + width: 100px; +`; + const HeaderDivideLine = styled.hr` width: 1px; height: 16px; - margin: 0 10px 0 38px; +`; + +const RankingContent = styled.div` + display: flex; + align-items: center; + margin: 0 10px; + width: 320px; + height: 25px; + cursor: pointer; `; const DivideLine = styled.hr` @@ -110,26 +112,29 @@ const RankingKeywordContainer = styled.ul` margin-bottom: 10px; `; +const KeywordIndex = styled.span` + width: 20px; +`; + +const Keyword = styled(Ellipsis)` + ${({ theme }) => theme.TYPO.body1}; + display: block; + width: 100%; +`; + const KeywordContainer = styled.li` display: flex; + width: 100%; gap: 15px; cursor: pointer; :hover { - span:last-of-type { + ${Keyword} { ${({ theme }) => theme.TYPO.body_h}; text-decoration: underline; } } `; -const KeywordIndex = styled.span` - width: 20px; -`; - -const Keyword = styled.span` - ${({ theme }) => theme.TYPO.body1}; -`; - const Dimmer = styled.div` position: fixed; top: 0; diff --git a/frontend/src/pages/Main/components/RankingSlide.tsx b/frontend/src/pages/Main/components/RankingSlide.tsx index 183d3d8..4e3d1b7 100644 --- a/frontend/src/pages/Main/components/RankingSlide.tsx +++ b/frontend/src/pages/Main/components/RankingSlide.tsx @@ -1,4 +1,5 @@ import { useInterval } from '@/hooks'; +import { Ellipsis } from '@/style/styleUtils'; import { useState } from 'react'; import styled from 'styled-components'; @@ -20,7 +21,6 @@ interface ISlideProps { const SLIDE_DELAY = 2500; const TRANSITION_TIME = 1500; const TRANSITION_SETTING = `transform linear ${TRANSITION_TIME}ms`; -const MAX_KEYWORD_LENGTH = 20; const RankingSlide = ({ rankingData }: IRankingSlideProps) => { const [keywordIndex, setKeywordIndex] = useState(0); @@ -53,12 +53,8 @@ const RankingSlide = ({ rankingData }: IRankingSlideProps) => { {newRankingData.map((data, index) => ( - {index === dataSize - 1 ? 1 : index + 1} - - {data.keyword.length > MAX_KEYWORD_LENGTH - ? `${data.keyword.slice(0, MAX_KEYWORD_LENGTH)}...` - : data.keyword} - + {index === dataSize - 1 ? 1 : index + 1} + {data.keyword} ))} @@ -70,6 +66,7 @@ const Container = styled.div` display: flex; flex-direction: column; justify-content: flex-start; + width: 100%; height: 25px; overflow-y: hidden; `; @@ -77,6 +74,7 @@ const Container = styled.div` const Slide = styled.ul` display: flex; flex-direction: column; + width: 100%; transition: ${(props) => props.transition}; transform: ${(props) => `translateY(${(-100 / props.dataSize) * props.keywordIndex}%)`}; `; @@ -93,4 +91,14 @@ const SlideItem = styled.li` } `; +const KeywordIndex = styled.span` + width: 20px; +`; + +const Keyword = styled(Ellipsis)` + ${({ theme }) => theme.TYPO.body1}; + display: block; + width: 100%; +`; + export default RankingSlide; diff --git a/frontend/src/pages/PaperDetail/components/InfoTooltip.tsx b/frontend/src/pages/PaperDetail/components/InfoTooltip.tsx index b5f06d1..ae9dc5e 100644 --- a/frontend/src/pages/PaperDetail/components/InfoTooltip.tsx +++ b/frontend/src/pages/PaperDetail/components/InfoTooltip.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import styled from 'styled-components'; -import IconButton from '../../../components/IconButton'; -import InfoIcon from '../../../icons/InfoIcon'; -import { getSessionStorage, setSessionStorage } from '../../../utils/storage'; +import IconButton from '@/components/IconButton'; +import InfoIcon from '@/icons/InfoIcon'; +import { getSessionStorage, setSessionStorage } from '@/utils/storage'; interface InfoContainerProps { isOpened: boolean; @@ -26,7 +26,7 @@ const InfoTooltip = () => { return ( - } onClick={handleInfoButtonClick} aria-label="정보" /> + } onClick={handleInfoButtonClick} aria-label="정보" /> 그래프 사용법 diff --git a/frontend/src/pages/PaperDetail/components/PaperInfo.tsx b/frontend/src/pages/PaperDetail/components/PaperInfo.tsx index 1b65f64..0ae726c 100644 --- a/frontend/src/pages/PaperDetail/components/PaperInfo.tsx +++ b/frontend/src/pages/PaperDetail/components/PaperInfo.tsx @@ -101,6 +101,8 @@ const InfoItem = styled.div` } a { ${({ theme }) => theme.TYPO.body2}; + word-wrap: break-word; + line-height: 1.1em; :hover { text-decoration: underline; } @@ -121,8 +123,20 @@ const References = styled.div` display: flex; flex-direction: column; gap: 20px; - overflow-y: scroll; + overflow-y: auto; overflow-x: hidden; + flex: 1; + ::-webkit-scrollbar { + width: 8px; + } + ::-webkit-scrollbar-track { + background-color: transparent; + } + ::-webkit-scrollbar-thumb { + background-color: ${({ theme }) => theme.COLOR.black}; + border-radius: 4px; + } + h3 { ${({ theme }) => theme.TYPO.body_h}; padding: 0 15px; diff --git a/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx b/frontend/src/pages/PaperDetail/components/ReferenceGraph.tsx index 91ca762..920b73c 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,58 @@ 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 (!svgRef.current || (nodes.length === 0 && links.length === 0)) 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(); + workerRef.current.onmessage = (event) => { + const { newNodes, newLinks } = event.data as { newNodes: Node[]; newLinks: Link[] }; + if (!newLinks) 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' }); + } + }); +}; diff --git a/frontend/src/pages/SearchList/components/Paper.tsx b/frontend/src/pages/SearchList/components/Paper.tsx index ee9aa35..a91847a 100644 --- a/frontend/src/pages/SearchList/components/Paper.tsx +++ b/frontend/src/pages/SearchList/components/Paper.tsx @@ -49,6 +49,7 @@ const Container = styled.div` const Title = styled.div` color: ${({ theme }) => theme.COLOR.black}; ${({ theme }) => theme.TYPO.title} + line-height: 1.1em; cursor: pointer; :hover { text-decoration: underline; diff --git a/frontend/src/pages/SearchList/components/SearchResults.tsx b/frontend/src/pages/SearchList/components/SearchResults.tsx index 0cdf1b6..df4ea31 100644 --- a/frontend/src/pages/SearchList/components/SearchResults.tsx +++ b/frontend/src/pages/SearchList/components/SearchResults.tsx @@ -1,7 +1,10 @@ import { IGetSearch } from '@/api/api'; -import { Pagination } from '@/components'; +import { IconButton, Pagination } from '@/components'; +import InfoIcon from '@/icons/InfoIcon'; import { useSearchQuery } from '@/queries/queries'; +import theme from '@/style/theme'; import { createDetailQuery } from '@/utils/createQueryString'; +import { useState } from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; import Paper from './Paper'; @@ -12,13 +15,28 @@ interface SearchResultsProps { } const SearchResults = ({ params, changePage }: SearchResultsProps) => { + const [isTooltipOpened, setIsTooltipOpened] = useState(false); const keyword = params.keyword || ''; const page = Number(params.page); const { data } = useSearchQuery(params); + const handleMouseOver = () => { + setIsTooltipOpened(true); + }; + + const handleMouseOut = () => { + setIsTooltipOpened(false); + }; + return data && data.papers.length > 0 ? ( <> -

Articles ({data.pageInfo.totalItems.toLocaleString() || 0})

+ +

Articles ({data.pageInfo.totalItems.toLocaleString() || 0})

+ + } aria-label="정보" /> + + {isTooltipOpened && 논문의 정보가 정확하지 않거나 누락되어 있을 수 있습니다.} +

@@ -36,12 +54,34 @@ const SearchResults = ({ params, changePage }: SearchResultsProps) => { ); }; +const SectionHeader = styled.div` + display: flex; + align-items: center; +`; + const H1 = styled.h1` color: ${({ theme }) => theme.COLOR.gray4}; - margin: 16px 30px; + margin: 16px 15px 16px 30px; ${({ theme }) => theme.TYPO.H5} `; +const IconButtonWrapper = styled.div` + opacity: 0.5; + cursor: pointer; + z-index: 10; + :hover { + opacity: 1; + } +`; + +const InfoTooltip = styled.span` + ${({ theme }) => theme.TYPO.body1}; + font-weight: 700; + padding: 5px 8px; + margin-left: 10px; + color: ${({ theme }) => theme.COLOR.gray4}; +`; + const Hr = styled.hr` border-top: 1px solid ${({ theme }) => theme.COLOR.gray2}; margin: 0; diff --git a/frontend/src/style/styleUtils.ts b/frontend/src/style/styleUtils.ts index 8848ec7..1bc1c1c 100644 --- a/frontend/src/style/styleUtils.ts +++ b/frontend/src/style/styleUtils.ts @@ -8,6 +8,7 @@ export const Ellipsis = styled.span` -webkit-line-clamp: 1; -webkit-box-orient: vertical; word-break: keep-all; + line-height: 1.1em; `; export const Emphasize = styled.span` diff --git a/frontend/src/utils/format.tsx b/frontend/src/utils/format.tsx index 21d3594..7958137 100644 --- a/frontend/src/utils/format.tsx +++ b/frontend/src/utils/format.tsx @@ -27,9 +27,9 @@ export const sliceTitle = (title: string) => { }; export const isDoiFormat = (doi: string) => { - return RegExp(/^https:\/\/doi.org\/([\d]{2}\.[\d]{1,}\/.*)/i).test(doi); + return RegExp(/^(https:\/\/doi.org\/)*([\d]{2}\.[\d]{1,}\/.*)$/i).test(doi); }; export const getDoiKey = (doi: string) => { - return doi.match(RegExp(/^https:\/\/doi.org\/([\d]{2}\.[\d]{1,}\/.*)/i))?.[1] || ''; + return doi.match(RegExp(/^(https:\/\/doi.org\/)*([\d]{2}\.[\d]{1,}\/.*)$/i))?.[2] || ''; };