Skip to content

Commit 7ab33f8

Browse files
authored
Merge pull request #168 from sipe-team/feat/5th-recruit
feat: 채용 데이터 시각화 차트 및 스타일 추가
2 parents ebccb1a + 3a99efa commit 7ab33f8

File tree

9 files changed

+504
-0
lines changed

9 files changed

+504
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import RecruitBarChart from '../RecruitBarChart';
2+
3+
const rawData = [
4+
{
5+
name: '대기업',
6+
value: 18,
7+
examples: '토스, 네이버, 카카오, 두나무 등',
8+
},
9+
{
10+
name: '스타트업/중견기업',
11+
value: 34,
12+
examples: '당근, 무신사, 파수, 팀스파르타 등',
13+
},
14+
{
15+
name: '창업/프리랜서',
16+
value: 8,
17+
examples: '프리랜서, 창업 등',
18+
},
19+
];
20+
21+
const total = rawData.reduce((sum, item) => sum + item.value, 0);
22+
23+
const data = rawData.map((item) => ({
24+
...item,
25+
percentage: Number(((item.value / total) * 100).toFixed(1)),
26+
}));
27+
28+
function CompanyChart() {
29+
return (
30+
<RecruitBarChart
31+
title="회사별 분포는 어떻게 되나요?"
32+
data={data}
33+
barColor="#FFB24D"
34+
/>
35+
);
36+
}
37+
38+
export default CompanyChart;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import RecruitBarChart from '../RecruitBarChart';
2+
3+
const rawData = [
4+
{ name: '1~3년차', value: 37 },
5+
{ name: '4~5년차', value: 13 },
6+
{ name: '6년차 이상', value: 10 },
7+
];
8+
9+
const total = rawData.reduce((sum, item) => sum + item.value, 0);
10+
11+
const data = rawData.map((item) => ({
12+
...item,
13+
percentage: Number(((item.value / total) * 100).toFixed(1)),
14+
}));
15+
16+
function ExperienceChart() {
17+
return (
18+
<RecruitBarChart
19+
title="연차별 분포는 어떻게 되나요?"
20+
data={data}
21+
barColor="#FFB24D"
22+
/>
23+
);
24+
}
25+
26+
export default ExperienceChart;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import RecruitBarChart from '../RecruitBarChart';
2+
3+
const rawData = [
4+
{ name: 'BE', value: 28 },
5+
{ name: 'FE', value: 21 },
6+
{ name: 'Full-stack', value: 3 },
7+
{ name: 'AI', value: 3 },
8+
// { name: 'Android', value: 2 },
9+
// { name: 'iOS', value: 1 },
10+
{ name: '기타', value: 5, examples: 'Android, iOS, Unity, DevOps 등' },
11+
];
12+
13+
const total = rawData.reduce((sum, item) => sum + item.value, 0);
14+
15+
const data = rawData.map((item) => ({
16+
...item,
17+
percentage: Number(((item.value / total) * 100).toFixed(1)),
18+
}));
19+
20+
function JobRoleChart() {
21+
return (
22+
<RecruitBarChart
23+
title="직군별 분포는 어떻게 되나요?"
24+
data={data}
25+
barColor="#FFB24D"
26+
barWidthMultiplier={20}
27+
/>
28+
);
29+
}
30+
31+
export default JobRoleChart;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
.wrapper {
2+
width: 100%;
3+
display: flex;
4+
flex-direction: column;
5+
gap: 24px;
6+
padding: 20px;
7+
border-radius: 12px;
8+
background-color: color('gray000');
9+
outline: 1px solid color('gray100');
10+
}
11+
12+
.titleWrapper {
13+
padding: 12px 20px;
14+
}
15+
16+
.title {
17+
font-size: 17px;
18+
font-weight: 700;
19+
line-height: 1.45;
20+
letter-spacing: -0.2%;
21+
color: color('white');
22+
}
23+
24+
.barItem {
25+
background-color: color('gray100');
26+
padding: 16px 20px;
27+
border-radius: 8px;
28+
position: relative;
29+
cursor: pointer;
30+
transition: background-color 0.2s ease;
31+
32+
&:focus {
33+
outline: 2px solid color('primary');
34+
outline-offset: 2px;
35+
}
36+
37+
&:hover,
38+
&:focus {
39+
background-color: color('gray200');
40+
}
41+
}
42+
43+
.label {
44+
font-size: 17px;
45+
font-weight: 600;
46+
line-height: 1.45;
47+
color: color('gray700');
48+
margin-bottom: 10px;
49+
letter-spacing: -0.2%;
50+
}
51+
52+
.barWrapper {
53+
display: flex;
54+
align-items: center;
55+
gap: 10px;
56+
position: relative;
57+
}
58+
59+
.bar {
60+
height: 35px;
61+
transition: width 1s ease-out;
62+
}
63+
64+
.value {
65+
font-size: 15px;
66+
font-weight: 600;
67+
color: color('primary');
68+
white-space: nowrap;
69+
}
70+
71+
.tooltip {
72+
position: absolute;
73+
background-color: rgba(255, 255, 255, 0.95);
74+
border: 1px solid #e5e7eb;
75+
border-radius: 8px;
76+
padding: 12px 16px;
77+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
78+
z-index: 10;
79+
pointer-events: none;
80+
min-width: 200px;
81+
max-width: 320px;
82+
}
83+
84+
.tooltipLabel {
85+
font-weight: 600;
86+
margin: 0 0 4px 0;
87+
color: #374151;
88+
font-size: 14px;
89+
}
90+
91+
.tooltipValue {
92+
color: #6b7280;
93+
font-size: 14px;
94+
margin: 0 0 8px 0;
95+
}
96+
97+
.tooltipExamples {
98+
color: #9ca3af;
99+
font-size: 13px;
100+
font-style: italic;
101+
line-height: 1.4;
102+
margin: 0;
103+
}
104+
105+
.visuallyHidden {
106+
position: absolute;
107+
width: 1px;
108+
height: 1px;
109+
padding: 0;
110+
margin: -1px;
111+
overflow: hidden;
112+
clip: rect(0, 0, 0, 0);
113+
white-space: nowrap;
114+
border: 0;
115+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
5+
import { useCountAnimation } from '@/hooks/useCountAnimation';
6+
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
7+
8+
import styles from './index.module.scss';
9+
10+
interface BarChartData {
11+
name: string;
12+
value: number;
13+
percentage: number;
14+
examples?: string;
15+
}
16+
17+
interface RecruitBarChartProps {
18+
title: string;
19+
data: BarChartData[];
20+
barColor?: string;
21+
barWidthMultiplier?: number;
22+
}
23+
24+
interface BarItemProps {
25+
item: BarChartData;
26+
barColor: string;
27+
barWidthMultiplier: number;
28+
maxValue: number;
29+
onMouseEnter: () => void;
30+
onMouseLeave: () => void;
31+
onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
32+
isHovered: boolean;
33+
mousePosition: { x: number; y: number };
34+
}
35+
36+
function BarItem({
37+
item,
38+
barColor,
39+
barWidthMultiplier,
40+
maxValue,
41+
onMouseEnter,
42+
onMouseLeave,
43+
onMouseMove,
44+
isHovered,
45+
mousePosition,
46+
}: BarItemProps) {
47+
const { ref, isVisible } = useIntersectionObserver<HTMLDivElement>({
48+
threshold: 0.1,
49+
});
50+
51+
const displayValue = useCountAnimation({
52+
end: item.value,
53+
duration: 3000,
54+
delay: 500,
55+
enabled: isVisible,
56+
});
57+
58+
return (
59+
<div
60+
ref={ref}
61+
className={styles.barItem}
62+
tabIndex={0}
63+
role="button"
64+
aria-label={`${item.name}: ${item.value}명, ${item.percentage}퍼센트`}
65+
onMouseEnter={onMouseEnter}
66+
onMouseLeave={onMouseLeave}
67+
onMouseMove={onMouseMove}
68+
onFocus={onMouseEnter}
69+
onBlur={onMouseLeave}
70+
>
71+
<p className={styles.label}>{item.name}</p>
72+
<div className={styles.barWrapper}>
73+
<div
74+
className={styles.bar}
75+
style={{
76+
width: isVisible ? `${item.value * barWidthMultiplier}px` : '0px',
77+
maxWidth: `${(item.value / maxValue) * 90}%`,
78+
backgroundColor: barColor,
79+
}}
80+
/>
81+
<span className={styles.value}>{displayValue}</span>
82+
</div>
83+
{isHovered && (
84+
<div
85+
className={styles.tooltip}
86+
style={{
87+
left: `${mousePosition.x + 12}px`,
88+
top: `${mousePosition.y + 12}px`,
89+
}}
90+
>
91+
<p className={styles.tooltipLabel}>{item.name}</p>
92+
<p className={styles.tooltipValue}>
93+
{item.value}명 ({item.percentage}%)
94+
</p>
95+
{item.examples && (
96+
<p className={styles.tooltipExamples}>{item.examples}</p>
97+
)}
98+
</div>
99+
)}
100+
</div>
101+
);
102+
}
103+
104+
function RecruitBarChart({
105+
title,
106+
data,
107+
barColor = '#FFB24D',
108+
barWidthMultiplier = 10,
109+
}: RecruitBarChartProps) {
110+
const [hoveredItem, setHoveredItem] = useState<BarChartData | null>(null);
111+
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
112+
113+
const maxValue = Math.max(...data.map((d) => d.value));
114+
115+
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
116+
const rect = e.currentTarget.getBoundingClientRect();
117+
setMousePosition({
118+
x: e.clientX - rect.left,
119+
y: e.clientY - rect.top,
120+
});
121+
};
122+
123+
return (
124+
<section className={styles.wrapper} aria-label={`${title} 차트`}>
125+
<div className={styles.visuallyHidden}>
126+
{title}: {data.map((item) => `${item.name} ${item.value}명`).join(', ')}
127+
</div>
128+
<div className={styles.titleWrapper}>
129+
<h3 className={styles.title}>{title}</h3>
130+
</div>
131+
{data.map((item) => (
132+
<BarItem
133+
key={item.name}
134+
item={item}
135+
barColor={barColor}
136+
barWidthMultiplier={barWidthMultiplier}
137+
maxValue={maxValue}
138+
onMouseEnter={() => setHoveredItem(item)}
139+
onMouseLeave={() => setHoveredItem(null)}
140+
onMouseMove={handleMouseMove}
141+
isHovered={hoveredItem?.name === item.name}
142+
mousePosition={mousePosition}
143+
/>
144+
))}
145+
</section>
146+
);
147+
}
148+
149+
export default RecruitBarChart;

src/components/pages/Recruit/index.module.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,24 @@
99
.faqWrapper {
1010
padding: 0 20px;
1111
}
12+
13+
.chartsWrapper {
14+
display: flex;
15+
flex-wrap: wrap;
16+
width: 100%;
17+
gap: 32px;
18+
}
19+
20+
.chartSection {
21+
width: 100%;
22+
max-width: 514px;
23+
24+
@include mobile-and-tablet {
25+
max-width: 100%;
26+
}
27+
}
28+
29+
.jobRoleChartSection {
30+
width: 100%;
31+
max-width: 100%;
32+
}

0 commit comments

Comments
 (0)