diff --git a/frontend/src/mocks/mockData/reviewCollection.ts b/frontend/src/mocks/mockData/reviewCollection.ts index a2db044e2..94aa20786 100644 --- a/frontend/src/mocks/mockData/reviewCollection.ts +++ b/frontend/src/mocks/mockData/reviewCollection.ts @@ -33,7 +33,7 @@ export const GROUPED_REVIEWS_MOCK_DATA: GroupedReviews = { { content: '팀의 분위기를 주도해요', count: 3 }, { content: '주장을 이야기할 때에는 합당한 근거가 뒤따라요', count: 2 }, { content: '팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요', count: 2 }, - { content: '팀 내 주어진 요구사항에 우선순위를 잘 매겨요 (커뮤니케이션 능력을 특화하자)', count: 1 }, + { content: '팀 내 주어진 요구사항에 우선순위를 잘 매겨요', count: 1 }, { content: '서로 다른 분야간의 소통도 중요하게 생각해요', count: 1 }, ], }, diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx new file mode 100644 index 000000000..7464323cf --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/index.tsx @@ -0,0 +1,84 @@ +import theme from '@/styles/theme'; +import { ReviewVotes } from '@/types'; + +import generateGradientColors from '../../utils/generateGradientColors'; +import DoughnutChartDetails from '../DoughnutChartDetails'; + +import * as S from './styles'; + +const DOUGHNUT_COLOR = { + START: `${theme.colors.primary}`, + END: '#e7e3f9', +}; + +const DoughnutChart = ({ reviewVotes }: { reviewVotes: ReviewVotes[] }) => { + const radius = 90; // 차트의 반지름 + const circumference = 2 * Math.PI * radius; // 차트의 둘레 + const centerX = 125; // svg의 중앙 좌표 (x) + const centerY = 125; // svg의 중앙 좌표 (y) + + const total = reviewVotes.reduce((acc, reviewVote) => acc + reviewVote.count, 0); + const ratios = reviewVotes.map((reviewVote) => reviewVote.count / total); + + // 누적 값 계산 + const acc = reviewVotes.reduce( + (arr, reviewVote) => { + const last = arr[arr.length - 1]; + return [...arr, last + reviewVote.count]; // 현재 값과 이전 누적 값을 더해 새로운 배열 반환 + }, + [0], + ); + + // 색상 시작 및 끝값 정의 + const colors = generateGradientColors(reviewVotes.length, DOUGHNUT_COLOR.START, DOUGHNUT_COLOR.END); + + // 각 조각의 중심 좌표를 계산하는 함수 + const calculateLabelPosition = (startAngle: number, endAngle: number) => { + const midAngle = (startAngle + endAngle) / 2; // 중간 각도 + const labelRadius = radius * 1; // 텍스트가 배치될 반지름 (차트 내부) + const x = centerX + labelRadius * Math.cos((midAngle * Math.PI) / 180); + const y = centerY + labelRadius * Math.sin((midAngle * Math.PI) / 180); + return { x, y }; + }; + + return ( + + + {reviewVotes.map((reviewVote, index) => { + const ratio = reviewVote.count / total; + const fillSpace = circumference * ratio; + const emptySpace = circumference - fillSpace; + const offset = (acc[index] / total) * circumference; + + // 시작 각도와 끝 각도를 계산 + const startAngle = (acc[index] / total) * 360 + 90; + const endAngle = ((acc[index] + reviewVote.count) / total) * 360 - 90; + + // 비율 레이블의 위치를 계산 + const { x, y } = calculateLabelPosition(startAngle, endAngle); + + return ( + + + + {Math.floor(ratios[index] * 100)}% + + + ); + })} + + + + ); +}; + +export default DoughnutChart; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts new file mode 100644 index 000000000..af15be420 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChart/styles.ts @@ -0,0 +1,15 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const DoughnutChartContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + + gap: 5rem; + + ${media.small} { + flex-direction: column; + } +`; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx new file mode 100644 index 000000000..93a2e7883 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/index.tsx @@ -0,0 +1,26 @@ +import { ReviewVotes } from '@/types'; + +import * as S from './styles'; + +interface DoughnutChartDetails { + reviewVotes: ReviewVotes[]; + colors: string[]; +} + +const DoughnutChartDetails = ({ reviewVotes, colors }: DoughnutChartDetails) => { + return ( + + {reviewVotes.map((reviewVote, index) => ( + + + + {reviewVote.content} + + {reviewVote.count}표 + + ))} + + ); +}; + +export default DoughnutChartDetails; diff --git a/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts new file mode 100644 index 000000000..899ae23b6 --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/components/DoughnutChartDetails/styles.ts @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; + +import media from '@/utils/media'; + +export const DoughnutChartDetailList = styled.div` + display: flex; + flex-direction: column; + + gap: 2rem; + + margin: 2rem; + + ${media.small} { + margin: 0 1rem; + } +`; + +export const DetailItem = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + gap: 1rem; +`; + +export const ContentContainer = styled.div` + display: flex; + align-items: center; + + gap: 1rem; +`; + +export const ChartColor = styled.div<{ color: string }>` + background-color: ${({ color }) => color}; + + width: 2rem; + height: 2rem; + + border-radius: 0.5rem; + flex-shrink: 0; + + ${media.small} { + width: 1.6rem; + height: 1.6rem; + } +`; + +export const Description = styled.span` + ${media.small} { + font-size: ${({ theme }) => theme.fontSize.small}; + } +`; + +export const ReviewVoteResult = styled.span` + ${media.small} { + font-size: ${({ theme }) => theme.fontSize.small}; + } +`; diff --git a/frontend/src/pages/ReviewCollectionPage/index.tsx b/frontend/src/pages/ReviewCollectionPage/index.tsx index b5ee31492..3bd098ace 100644 --- a/frontend/src/pages/ReviewCollectionPage/index.tsx +++ b/frontend/src/pages/ReviewCollectionPage/index.tsx @@ -5,6 +5,7 @@ import ReviewDisplayLayout from '@/components/layouts/ReviewDisplayLayout'; import { useGetReviewList } from '@/hooks'; import { GROUPED_REVIEWS_MOCK_DATA, GROUPED_SECTION_MOCK_DATA } from '@/mocks/mockData/reviewCollection'; +import DoughnutChart from './components/DoughnutChart'; import * as S from './styles'; const ReviewCollectionPage = () => { @@ -34,7 +35,7 @@ const ReviewCollectionPage = () => { return ( {review.question.type === 'CHECKBOX' ? ( -

객관식 통계 차트

+ ) : ( {review.answers?.map((answer, index) => { diff --git a/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts b/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts new file mode 100644 index 000000000..87059804b --- /dev/null +++ b/frontend/src/pages/ReviewCollectionPage/utils/generateGradientColors.ts @@ -0,0 +1,33 @@ +// Hex 색상을 RGB로 변환하는 함수 +const hexToRGB = (hex: string) => { + const bigint = parseInt(hex.slice(1), 16); + return [bigint >> 16, (bigint >> 8) & 255, bigint & 255]; +}; + +// RGB 색상을 Hex로 변환하는 함수 +const rgbToHex = (r: number, g: number, b: number) => { + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; +}; + +// 두 색상 사이의 색상을 계산하는 함수 +const interpolateColor = (start: number[], end: number[], factor: number) => { + const result = start.map((startValue, index) => Math.round(startValue + factor * (end[index] - startValue))); + return result; +}; + +// reviewVotes 길이에 따라 색상 배열을 생성하는 함수 +const generateGradientColors = (length: number, startHex: string, endHex: string) => { + const startColor = hexToRGB(startHex); + const endColor = hexToRGB(endHex); + const colors = []; + + for (let i = 0; i < length; i++) { + const factor = i / (length - 1); + const color = interpolateColor(startColor, endColor, factor); + colors.push(rgbToHex(color[0], color[1], color[2])); + } + + return colors; +}; + +export default generateGradientColors;