Skip to content

Commit

Permalink
[FE] feat: 리뷰 모아보기에서 질문별 통계 차트 구현 (#803)
Browse files Browse the repository at this point in the history
* feat: Switch 컴포넌트 제작

* chore: Switch 컴포넌트 이름 변경

* feat: 리뷰 모아보기에 대한 라우팅 추가 및 임시 페이지 구현

* feat: 리뷰 모아보기와 리뷰 목록 페이지의 공통 레이아웃 제작

* refactor: 공통 레이아웃 제작에 따른 ReviewList 리팩토링

* feat: 리뷰 목록 반응형 적용

* feat: OptionSwitch 반응형 적용

* refactor: ReviewDisplayLayout 반응형 수정

* feat: 공통 Dropdown 컴포넌트 작성

* design: 화살표 버튼 오른쪽 고정 및 옵션을 드래그하지 못하게 수정

* feat: Dropdown 외부 클릭 시 닫히도록 하는 기능 구현

* refactor: Dropdown 로직을 훅으로 분리

* chore: props 명칭 변경 및 선택된 아이템 ellipsis 처리

* feat: Accordion 공통 컴포넌트 작성

* chore: index에 Dropdown, Accordion 추가

* feat: theme에 Dropdown의 z-index 추가

* chore: 누락된 index 추가

* design: Dropdown border 색상 변경

* refactor: Accordion 로직 훅으로 분리

* fix: px을 rem으로 수정

* design: Dropdown 및 Accordion의 margin-bottom 속성 제거

* feat: 초기에 열려있는 Accordion 구현을 위해 prop 추가

* feat: 모아보기 페이지 type 정의

* feat: 모아보기 페이지 목 데이터 작성

* design: Accordion 컴포넌트에서 불필요한 props 제거

* design: Accordion 반응형 구현

* feat: 목 데이터를 사용하여 모아보기 페이지 퍼블리싱

* feat: 질문별 통계 차트 구현

* feat: 통계 차트 세부사항 표시

* feat: 질문 길이에 따라 색상을 생성하는 함수 구현

* chore: DoughnutChart 파일 위치 변경

* feat: 차트 애니메이션 적용 및 비율을 텍스트로 시각화

* chore: 비율을 'n표' 형식으로 수정

* chore: 불필요한 텍스트 제거

* feat: ReviewCollectionPage에 DoughnutChart 컴포넌트 적용

* feat: 통계 차트에 반응형 적용

* refactor: theme에 있는 primary 색상 활용

* chore: 불필요한 코드 제거

* refactor: 차트 애니메이션 제거

* chore: Accordion에 넘겨주는 props 변수명 수정

---------

Co-authored-by: ImxYJL <allensain14@gmail.com>
Co-authored-by: chysis <chysiss@naver.com>
  • Loading branch information
3 people authored and skylar1220 committed Nov 5, 2024
1 parent 2878453 commit 50dc8ed
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 2 deletions.
2 changes: 1 addition & 1 deletion frontend/src/mocks/mockData/reviewCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<S.DoughnutChartContainer>
<svg viewBox="0 0 250 250" width="250" height="250">
{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 (
<g key={index}>
<circle
cx={centerX} // 중앙에 배치
cy={centerY}
r={radius}
fill="none"
stroke={colors[index]}
strokeWidth="65"
strokeDasharray={`${fillSpace} ${emptySpace}`} // 조각의 길이와 나머지 길이 설정
strokeDashoffset={-offset} // 시작 위치 설정
/>
<text x={x} y={y} textAnchor="middle" dominantBaseline="middle" fontSize="14">
{Math.floor(ratios[index] * 100)}%
</text>
</g>
);
})}
</svg>
<DoughnutChartDetails reviewVotes={reviewVotes} colors={colors} />
</S.DoughnutChartContainer>
);
};

export default DoughnutChart;
Original file line number Diff line number Diff line change
@@ -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;
}
`;
Original file line number Diff line number Diff line change
@@ -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 (
<S.DoughnutChartDetailList>
{reviewVotes.map((reviewVote, index) => (
<S.DetailItem key={reviewVote.content}>
<S.ContentContainer>
<S.ChartColor color={colors[index]}></S.ChartColor>
<S.Description>{reviewVote.content}</S.Description>
</S.ContentContainer>
<S.ReviewVoteResult>{reviewVote.count}</S.ReviewVoteResult>
</S.DetailItem>
))}
</S.DoughnutChartDetailList>
);
};

export default DoughnutChartDetails;
Original file line number Diff line number Diff line change
@@ -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};
}
`;
3 changes: 2 additions & 1 deletion frontend/src/pages/ReviewCollectionPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -34,7 +35,7 @@ const ReviewCollectionPage = () => {
return (
<Accordion title={review.question.name} key={index} isInitiallyOpened={index === 0 ? true : false}>
{review.question.type === 'CHECKBOX' ? (
<p>객관식 통계 차트</p>
<DoughnutChart reviewVotes={review.votes!} />
) : (
<S.ReviewAnswerContainer>
{review.answers?.map((answer, index) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 50dc8ed

Please sign in to comment.