diff --git a/README.md b/README.md index 184aa75..f6f60bb 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ ![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) -### 서비스 링크 : https://paperef.com +### 서비스 배포 + +- Dev 서버 : http://49.50.172.204:3000/ + +- Production 서버 : http://101.101.217.49:3000/ ### 팀원 @@ -36,6 +39,7 @@ 최예윤 + ### 개발 환경 세팅 @@ -80,7 +84,6 @@ ELASTIC_USER= ELASTIC_PASSWORD= ALLOW_UPDATE= MAIL_TO= -SHOULD_RUN_BATCH= ``` ## 기술스택 @@ -97,7 +100,7 @@ SHOULD_RUN_BATCH= - 키워드 자동완성 검색 서비스 제공 - 키워드 검색 서비스 제공 - 논문 DOI를 통한 인용관계 시각화 서비스 제공 -- 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보 혹은 Crossref API를 통해 요청한 정보를 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 Request batch에 의해 처리되므로 검색 결과를 즉시 받아보지 못할 수 있습니다. +- 사용자는 키워드 검색시 PRV 데이터베이스에 있는 정보만 조회할 수 있으며, 데이터베이스에 없는 논문에 대한 데이터 수집은 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 816155d..16d7d3f 100644 --- a/backend/src/ranking/ranking.controller.ts +++ b/backend/src/ranking/ranking.controller.ts @@ -12,4 +12,8 @@ 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 a329a4d..ee0fcc5 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 service.getTen(); + const topTen = await controller.getTen(); expect(topTen.length).toBeLessThanOrEqual(10); }); it('데이터 삽입 후 topTen 체크', async () => { //Case 2. 데이터 삽입 후 topTen 체크 - const flag = await service.insertRedis('9번째 데이터'); + const flag = await controller.insertCache('9번째 데이터'); expect(flag).toBe('new'); const topTen = await controller.getTen(); expect(topTen.length).toBe(9); - const flag2 = await service.insertRedis('10번째 데이터'); + const flag2 = await controller.insertCache('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 service.insertRedis('사랑해요'); + const flag = await controller.insertCache('사랑해요'); 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 service.insertRedis('newData'); + const result = await controller.insertCache('newData'); expect(result).toBe('new'); }); // Case2. 기존 redis에 있던 데이터 삽입 it('기존 redis에 있던 데이터 삽입', async () => { - const result = await service.insertRedis('부스트캠프'); + const result = await controller.insertCache('부스트캠프'); expect(result).toBe('update'); }); //Case3. redis에 빈 검색어 입력 it('빈 검색어 redis에 삽입', async () => { - await expect(service.insertRedis('')).rejects.toEqual( + await expect(controller.insertCache('')).rejects.toEqual( new BadRequestException({ status: 400, error: 'bad request' }), ); }); //Case4. insert 실패시 타임 아웃 TimeOut it('insert 실패시 타임 아웃 TimeOut', async () => { - await expect(service.insertRedis('')).rejects.toEqual( + await expect(controller.insertCache('')).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 5d6ab75..2a2cf85 100644 --- a/backend/src/search/search.controller.ts +++ b/backend/src/search/search.controller.ts @@ -46,18 +46,6 @@ 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 b2e2b9a..007c227 100644 --- a/backend/src/search/search.service.ts +++ b/backend/src/search/search.service.ts @@ -1,16 +1,15 @@ -import { Injectable, NotFoundException, RequestTimeoutException } from '@nestjs/common'; +import { Injectable, NotFoundException } 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, CROSSREF_API_URL } from '../util'; +import { CROSSREF_API_PAPER_URL } from '../util'; import { ELASTIC_INDEX } from 'src/envLayer'; @Injectable() @@ -76,17 +75,6 @@ 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 d4a99fe..18ac270 100644 --- a/backend/src/util.ts +++ b/backend/src/util.ts @@ -1,12 +1,7 @@ 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[] = ['title', 'author', 'created', 'is-referenced-by-count', 'references-count', 'DOI'], -) => +export const CROSSREF_API_URL = (keyword: string, rows = 5, page = 1, selects: string[] = ['author', 'title', '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 118b2f7..3e5c5a9 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('문의사항, 버그제보: viewpoint.prv@gmail.com'); + expect(span?.textContent).toBe('문의사항, 버그제보: vp.prv@gmail.com'); }); diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 90d3733..72e20f6 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 ( - 문의사항, 버그제보: viewpoint.prv@gmail.com + 문의사항, 버그제보: vp.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 02a4cbb..e9fb8d3 100644 --- a/frontend/src/components/search/AutoCompletedList.tsx +++ b/frontend/src/components/search/AutoCompletedList.tsx @@ -79,7 +79,6 @@ 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 f981934..2b83dc3 100644 --- a/frontend/src/components/search/RecentKeywordsList.tsx +++ b/frontend/src/components/search/RecentKeywordsList.tsx @@ -1,6 +1,5 @@ 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'; @@ -43,7 +42,7 @@ const RecentKeywordsList = ({ onMouseDown={() => handleMouseDown(keyword)} > - {keyword} + {keyword} } onMouseDown={(e) => handleRecentKeywordRemove(e, keyword)} @@ -72,11 +71,6 @@ 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/useGraphData.ts b/frontend/src/hooks/graph/useGraphData.ts index 5e7de6b..a2967ad 100644 --- a/frontend/src/hooks/graph/useGraphData.ts +++ b/frontend/src/hooks/graph/useGraphData.ts @@ -1,10 +1,9 @@ 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(() => { @@ -28,7 +27,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); @@ -50,7 +49,7 @@ export default function useGraphData(data: IPaperDetail) { target: reference.key.toLowerCase(), })); setLinks((prev) => [...prev, ...newLinks]); - }, [data, links]); + }, [data]); - return { nodes: nodes.current, links }; + return { nodes: nodes.current, links } as T; } diff --git a/frontend/src/hooks/graph/useGraphEmphasize.ts b/frontend/src/hooks/graph/useGraphEmphasize.ts index b9ef96f..9369051 100644 --- a/frontend/src/hooks/graph/useGraphEmphasize.ts +++ b/frontend/src/hooks/graph/useGraphEmphasize.ts @@ -1,12 +1,11 @@ -import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph'; -import * as d3 from 'd3'; -import { useCallback, useEffect } from 'react'; import { useTheme } from 'styled-components'; +import * as d3 from 'd3'; +import { useEffect, useCallback } from 'react'; const styles = { EMPHASIZE_OPACITY: '1', BASIC_OPACITY: '0.5', - EMPHASIZE_STROKE_WIDTH: '1.5px', + EMPHASIZE_STROKE_WIDTH: '0.8px', BASIC_STROKE_WIDTH: '0.5px', EMPHASIZE_STROKE_DASH: 'none', BASIC_STROKE_DASH: '1', @@ -15,8 +14,8 @@ const styles = { export default function useGraphEmphasize( nodeSelector: SVGGElement | null, linkSelector: SVGGElement | null, - nodes: Node[], - links: Link[], + nodes: any[], + links: any[], hoveredNode: string, selectedKey: string, ) { @@ -41,53 +40,46 @@ export default function useGraphEmphasize( d3.select(nodeSelector) .selectAll('text') .data(nodes) - .filter((d) => { - return links - .filter((l) => l.source === hoveredNode) - .map((l) => l.target) - .includes(d.key); - }) + .filter((d) => + links + .filter((l) => l.source.key === hoveredNode) + .map((l) => l.target.key) + .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.secondary2); + .style('fill', theme.COLOR.secondary1); // click된 노드의 자식 노드들 강조 d3.select(nodeSelector) .selectAll('text') .data(nodes) - .filter((d) => { - const result = links - .filter((l) => l.source === selectedKey) - .map((l) => l.target) - .includes(d.key); - return result; - }) - .style('fill', theme.COLOR.secondary2); + .filter((d) => + links + .filter((l) => l.source.key === selectedKey) + .map((l) => l.target.key) + .includes(d.key), + ) + .style('fill', theme.COLOR.secondary1); // click/hover된 노드의 링크 강조 d3.select(linkSelector) .selectAll('line') .data(links) - .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', (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-dasharray', (d) => - getStyles(d.source as string, styles.EMPHASIZE_STROKE_DASH, styles.BASIC_STROKE_DASH), + getStyles(d.source.key, 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, nodes, links, selectedKey, linkSelector, getStyles, theme]); + }, [nodeSelector, hoveredNode, nodes, links, selectedKey, linkSelector, getStyles, theme]); } diff --git a/frontend/src/hooks/graph/useLinkUpdate.ts b/frontend/src/hooks/graph/useLinkUpdate.ts new file mode 100644 index 0000000..fa8d5e4 --- /dev/null +++ b/frontend/src/hooks/graph/useLinkUpdate.ts @@ -0,0 +1,18 @@ +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/graph/useGraph.ts b/frontend/src/hooks/graph/useNodeUpdate.ts similarity index 57% rename from frontend/src/hooks/graph/useGraph.ts rename to frontend/src/hooks/graph/useNodeUpdate.ts index 6c3ffda..f525058 100644 --- a/frontend/src/hooks/graph/useGraph.ts +++ b/frontend/src/hooks/graph/useNodeUpdate.ts @@ -1,33 +1,17 @@ -import { Link, Node } from '@/pages/PaperDetail/components/ReferenceGraph'; import theme from '@/style/theme'; import * as d3 from 'd3'; import { useCallback } from 'react'; -const useGraph = ( - nodeSelector: SVGGElement | null, - linkSelector: SVGGElement | null, - addChildrensNodes: (doi: string) => void, - 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 NORMAL_SYMBOL_SIZE = 20; +const STAR_SYMBOL_SIZE = 100; +export default function useNodeUpdate( + nodes: any[], + changeHoveredNode: (key: string) => void, + addChildrensNodes: (doi: string) => void, +) { + return useCallback( + (nodesSelector: SVGGElement) => { const normalSymbol = d3.symbol().type(d3.symbolSquare).size(NORMAL_SYMBOL_SIZE)(); const starSymbol = d3.symbol().type(d3.symbolStar).size(STAR_SYMBOL_SIZE)(); @@ -36,7 +20,7 @@ const useGraph = ( return d3.scaleLinear([0, 4], ['white', theme.COLOR.secondary2]).interpolate(d3.interpolateRgb)(loged); }; - d3.select(nodeSelector) + d3.select(nodesSelector) .selectAll('path') .data(nodes) .join('path') @@ -48,23 +32,18 @@ const useGraph = ( .on('mouseout', () => changeHoveredNode('')) .on('click', (_, d) => d.doi && addChildrensNodes(d.doi)); - d3.select(nodeSelector) + d3.select(nodesSelector) .selectAll('text') .data(nodes) .join('text') .text((d) => `${d.author} ${d.publishedYear ? `(${d.publishedYear})` : ''}`) - .attr('x', (d) => d.x || null) - .attr('y', (d) => (d.y ? d.y + 10 : null)) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y + 10) .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)); }, - [nodeSelector, addChildrensNodes, changeHoveredNode], + [nodes, addChildrensNodes, changeHoveredNode], ); - - return { drawLink, drawNode }; -}; - -export default useGraph; +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 850c245..d03db2c 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,6 +1,7 @@ -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 87d1259..1799cfd 100644 --- a/frontend/src/icons/InfoIcon.tsx +++ b/frontend/src/icons/InfoIcon.tsx @@ -1,13 +1,9 @@ -interface IProps { - color: string; -} - -const InfoIcon = ({ color }: IProps) => { +const InfoIcon = () => { return ( diff --git a/frontend/src/pages/Main/components/KeywordRanking.tsx b/frontend/src/pages/Main/components/KeywordRanking.tsx index 7aa1d10..9dadfcf 100644 --- a/frontend/src/pages/Main/components/KeywordRanking.tsx +++ b/frontend/src/pages/Main/components/KeywordRanking.tsx @@ -1,7 +1,6 @@ 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'; @@ -20,7 +19,7 @@ const KeywordRanking = () => { - 인기 검색어 + 인기 검색어 {!isLoading && rankingData?.length ? : '데이터가 없습니다.'} @@ -72,30 +71,29 @@ 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; - width: 100%; + position: relative; height: 23px; + width: 100%; ${({ theme }) => theme.TYPO.body_h} `; -const Title = styled.span` - width: 100px; -`; - const HeaderDivideLine = styled.hr` width: 1px; height: 16px; -`; - -const RankingContent = styled.div` - display: flex; - align-items: center; - margin: 0 10px; - width: 320px; - height: 25px; - cursor: pointer; + margin: 0 10px 0 38px; `; const DivideLine = styled.hr` @@ -112,29 +110,26 @@ 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 { - ${Keyword} { + span:last-of-type { ${({ 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 4e3d1b7..183d3d8 100644 --- a/frontend/src/pages/Main/components/RankingSlide.tsx +++ b/frontend/src/pages/Main/components/RankingSlide.tsx @@ -1,5 +1,4 @@ import { useInterval } from '@/hooks'; -import { Ellipsis } from '@/style/styleUtils'; import { useState } from 'react'; import styled from 'styled-components'; @@ -21,6 +20,7 @@ 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,8 +53,12 @@ const RankingSlide = ({ rankingData }: IRankingSlideProps) => { {newRankingData.map((data, index) => ( - {index === dataSize - 1 ? 1 : index + 1} - {data.keyword} + {index === dataSize - 1 ? 1 : index + 1} + + {data.keyword.length > MAX_KEYWORD_LENGTH + ? `${data.keyword.slice(0, MAX_KEYWORD_LENGTH)}...` + : data.keyword} + ))} @@ -66,7 +70,6 @@ const Container = styled.div` display: flex; flex-direction: column; justify-content: flex-start; - width: 100%; height: 25px; overflow-y: hidden; `; @@ -74,7 +77,6 @@ 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}%)`}; `; @@ -91,14 +93,4 @@ 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 ae9dc5e..b5f06d1 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 0ae726c..1b65f64 100644 --- a/frontend/src/pages/PaperDetail/components/PaperInfo.tsx +++ b/frontend/src/pages/PaperDetail/components/PaperInfo.tsx @@ -101,8 +101,6 @@ const InfoItem = styled.div` } a { ${({ theme }) => theme.TYPO.body2}; - word-wrap: break-word; - line-height: 1.1em; :hover { text-decoration: underline; } @@ -123,20 +121,8 @@ const References = styled.div` display: flex; flex-direction: column; gap: 20px; - overflow-y: auto; + overflow-y: scroll; 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 c16804f..91ca762 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 { useGraph, useGraphData, useGraphEmphasize, useGraphZoom } from '@/hooks'; -import { SimulationNodeDatum } from 'd3'; +import { useGraphData, useGraphEmphasize, useGraphZoom, useLinkUpdate, useNodeUpdate } from '@/hooks'; +import * as d3 from 'd3'; import { useEffect, useRef } from 'react'; import styled from 'styled-components'; import InfoTooltip from './InfoTooltip'; @@ -12,58 +12,43 @@ interface ReferenceGraphProps { changeHoveredNode: (key: string) => void; } -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; -} - +// Todo : any 걷어내기, 구조 리팩터링하기, 프론트 테스트 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(data); - const { drawLink, drawNode } = useGraph(nodeRef.current, linkRef.current, addChildrensNodes, changeHoveredNode); + const { nodes, links } = useGraphData<{ nodes: any[]; links: any[] }>(data); + + const updateLinks = useLinkUpdate(links); + const updateNodes = useNodeUpdate(nodes, changeHoveredNode, addChildrensNodes); useGraphZoom(svgRef.current); useGraphEmphasize(nodeRef.current, linkRef.current, nodes, links, hoveredNode, data.key); useEffect(() => { - if (!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 ticked = (linksSelector: SVGGElement, nodesSelector: SVGGElement) => { + updateLinks(linksSelector); + updateNodes(nodesSelector); + }; - workerRef.current.onmessage = (event) => { - const { newNodes, newLinks } = event.data as { newNodes: Node[]; newLinks: Link[] }; - if (!newLinks) return; - drawLink(newLinks); - drawNode(newNodes); + 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, drawLink, drawNode]); + }, [nodes, links, updateLinks, updateNodes]); return ( diff --git a/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts b/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts deleted file mode 100644 index 86b3d90..0000000 --- a/frontend/src/pages/PaperDetail/workers/forceSimulation.worker.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 a91847a..ee9aa35 100644 --- a/frontend/src/pages/SearchList/components/Paper.tsx +++ b/frontend/src/pages/SearchList/components/Paper.tsx @@ -49,7 +49,6 @@ 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 df4ea31..0cdf1b6 100644 --- a/frontend/src/pages/SearchList/components/SearchResults.tsx +++ b/frontend/src/pages/SearchList/components/SearchResults.tsx @@ -1,10 +1,7 @@ import { IGetSearch } from '@/api/api'; -import { IconButton, Pagination } from '@/components'; -import InfoIcon from '@/icons/InfoIcon'; +import { Pagination } from '@/components'; 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'; @@ -15,28 +12,13 @@ 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})

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

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


@@ -54,34 +36,12 @@ 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 15px 16px 30px; + margin: 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 1bc1c1c..8848ec7 100644 --- a/frontend/src/style/styleUtils.ts +++ b/frontend/src/style/styleUtils.ts @@ -8,7 +8,6 @@ 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 7958137..21d3594 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))?.[2] || ''; + return doi.match(RegExp(/^https:\/\/doi.org\/([\d]{2}\.[\d]{1,}\/.*)/i))?.[1] || ''; };