Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 41 additions & 26 deletions src/__tests__/utils/generateCategorySvg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,27 @@ describe('generateCategorySvg 유틸리티 함수 테스트', () => {
expect(result).toContain('<svg');
expect(result).toContain('</svg>');

// 카테고리 데이터가 SVG에 포함되어 있는지 확인
expect(result).toContain('web: 2000');
expect(result).toContain('pwnable: 1500');
expect(result).toContain('reversing: 1000');
expect(result).toContain('crypto: 500');

// 카테고리 이름이 SVG에 포함되어 있는지 확인 (첫 글자 대문자)
expect(result).toContain('Web');
expect(result).toContain('Pwnable');
expect(result).toContain('Reversing');
expect(result).toContain('Crypto');

// 퍼센트가 표시되는지 확인
expect(result).toContain('40%');
expect(result).toContain('30%');
expect(result).toContain('20%');
expect(result).toContain('10%');

// 색상이 적용되었는지 확인
expect(result).toContain('fill="#ff6b6b"');
expect(result).toContain('fill="#339af0"');

// 총점이 표시되는지 확인
expect(result).toContain('5000');

// 제목이 포함되어 있는지 확인
expect(result).toContain('Most solved categories');
expect(result).toContain('Most Solved Categories');
});

it('카테고리가 없는 경우 "No data"가 표시되어야 함', () => {
Expand Down Expand Up @@ -67,34 +73,43 @@ describe('generateCategorySvg 유틸리티 함수 테스트', () => {

const result = generateCategorySvg(mockStats);

// 상위 5개 카테고리는 범례에 표시되어야 함
expect(result).toContain('web: 3000');
expect(result).toContain('pwnable: 2500');
expect(result).toContain('reversing: 2000');
expect(result).toContain('crypto: 1500');
expect(result).toContain('forensic: 1000');

// 6번째 카테고리인 misc는 범례에 없어야 함
expect(result).not.toContain('misc: 500');
// 상위 5개 카테고리는 범례에 표시되어야 함 (첫 글자 대문자)
expect(result).toContain('Web');
expect(result).toContain('Pwnable');
expect(result).toContain('Reversing');
expect(result).toContain('Crypto');
expect(result).toContain('Forensic');

// 상위 5개의 퍼센트가 표시되어야 함
expect(result).toContain('29%');
expect(result).toContain('24%');
expect(result).toContain('19%');
expect(result).toContain('14%');
expect(result).toContain('10%');

// 6번째 카테고리인 misc는 범례에 표시되지만 차트에는 있음
// (범례는 최대 5개만 표시)
const legendMatches = result.match(/class="legend-text">/g) || [];
expect(legendMatches.length).toBe(5);
});

it('매우 작은 카테고리(<5%)는 퍼센트 라벨이 표시되지 않아야 함', () => {
it('매우 작은 카테고리(<8%)는 퍼센트 라벨이 표시되지 않아야 함', () => {
const mockStats: TCategoryStats = {
nickname: 'weakness',
total_score: 10000,
categories: [
{ name: 'web', score: 9600, rank: 50, color: '#ff6b6b' }, // 96%
{ name: 'misc', score: 400, rank: 300, color: '#20c997' } // 4% (5% 미만이므로 표시되지 않아야 함)
{ name: 'web', score: 9300, rank: 50, color: '#ff6b6b' }, // 93%
{ name: 'misc', score: 700, rank: 300, color: '#20c997' } // 7% (8% 미만이므로 표시되지 않아야 함)
]
};

const result = generateCategorySvg(mockStats);
// 퍼센트 라벨 확인 (4%는 표시되지 않아야 함)

// 퍼센트 라벨 확인 (7%는 표시되지 않아야 함)
const percentLabels = result.match(/class="percentage-label">(.*?)%</g) || [];
// 96%만 표시되어야 함

// 93%만 표시되어야 함
expect(percentLabels.length).toBe(1);
expect(percentLabels[0]).toContain('96%');
expect(percentLabels[0]).toContain('93%');
});
});
10 changes: 5 additions & 5 deletions src/pages/api/stats.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { Tstats } from '../../types';
import { generateStatsSvg } from '../../utils/generateStatsSvg';
import {
getLastRank,
calculateTopPercentage,
getUserId,
getUserData
import {
getLastRank,
calculateTopPercentage,
getUserId,
getUserData
} from '../../utils/dreamhack';

// 성능 측정을 위한 유틸리티 함수
Expand Down
16 changes: 8 additions & 8 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,17 @@ export default function Home() {

<div className={styles.example}>
<h3>예시:</h3>
<a
href="https://dreamhack.io/users/weakness"
target="_blank"
<a
href="https://dreamhack.io/users/weakness"
target="_blank"
rel="noopener noreferrer"
className={styles.exampleLink}
>
<img
src="/api/stats?username=weakness"
alt="Dreamhack stats example"
width={350}
height={170}
<img
src="/api/stats?username=weakness"
alt="Dreamhack stats example"
width={350}
height={170}
/>
</a>
</div>
Expand Down
56 changes: 56 additions & 0 deletions src/styles/Home.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,60 @@
.socialLink svg {
width: 24px;
height: 24px;
}

.themeDescription {
margin: 0.5rem 0 1.5rem 0;
color: #666;
font-size: 0.95rem;
}

.themeDescription code {
background: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}

.themeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}

.themeItem {
text-align: center;
padding: 1rem;
border: 1px solid #eaeaea;
border-radius: 8px;
background: #fafafa;
}

.themeItem h4 {
margin: 0 0 0.3rem 0;
font-size: 1.1rem;
color: #333;
}

.themeDesc {
margin: 0 0 1rem 0;
font-size: 0.85rem;
color: #666;
}

.themeItem .codeSection {
margin-top: 1rem;
text-align: left;
}

.themeItem .codeSection .code {
font-size: 0.75rem;
background: #fff;
}

@media (max-width: 800px) {
.themeGrid {
grid-template-columns: 1fr;
}
}
113 changes: 58 additions & 55 deletions src/utils/generateCategorySvg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,122 +4,125 @@ export function generateCategorySvg(stats: TCategoryStats): string {
// SVG 크기 및 차트 설정
const width = 390;
const height = 190;
const pieCenterX = 290; // 원형 차트 중심 X (왼쪽)
const pieCenterY = 96; // 원형 차트 중심 Y
const radius = 88; // 원형 차트 반지름
const pieCenterX = 295;
const pieCenterY = 105;
const radius = 70;

// 전체 스코어
const totalScore = stats.total_score;

// 카테고리 데이터
const categories = stats.categories;
const totalCategoryScore = categories.reduce((sum, category) => sum + category.score, 0);

// 원 차트 및 범례 생성
let pieChart = '';
let legends = '';
let currentAngle = 0;

const legendStartX = 25; // 범례 시작 X 위치 (오른쪽)
const legendStartY = 80; // 범례 시작 Y 위치 (제목 아래)
const legendItemHeight = 22; // 범례 항목 간 간격
const maxLegendItems = 5; // 표시할 최대 범례 항목 수
const legendStartX = 25;
const legendStartY = 75;
const legendItemHeight = 22;
const maxLegendItems = 5;

// 카테고리가 없는 경우
if (categories.length === 0 || totalCategoryScore === 0) {
pieChart = `<circle cx="${pieCenterX}" cy="${pieCenterY}" r="${radius}" fill="#ddd" />
pieChart = `<circle cx="${pieCenterX}" cy="${pieCenterY}" r="${radius}" fill="#e2e8f0" />
<text x="${pieCenterX}" y="${pieCenterY}" text-anchor="middle" class="no-data">No data</text>`;
legends = `<text x="${legendStartX}" y="${legendStartY + 10}" class="no-data">No category data</text>`;
} else {
// 상위 카테고리만 범례에 표시 (최대 maxLegendItems개)
const topCategories = categories.slice(0, maxLegendItems);

categories.forEach((category) => {
const percentage = category.score / totalCategoryScore;
const angleSize = percentage * 360;
const endAngle = currentAngle + angleSize;

// 원형 조각 경로
const startX = pieCenterX + radius * Math.cos((currentAngle - 90) * Math.PI / 180);
const startY = pieCenterY + radius * Math.sin((currentAngle - 90) * Math.PI / 180);
const endX = pieCenterX + radius * Math.cos((endAngle - 90) * Math.PI / 180);
const endY = pieCenterY + radius * Math.sin((endAngle - 90) * Math.PI / 180);
const largeArcFlag = angleSize > 180 ? 1 : 0;

const path = `
<path
d="M ${pieCenterX} ${pieCenterY} L ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY} Z"
fill="${category.color}"
stroke="white"
stroke-width="0.5"
<path
d="M ${pieCenterX} ${pieCenterY} L ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY} Z"
fill="${category.color}"
stroke="white"
stroke-width="1"
/>
`;

// 퍼센트 라벨
const midAngle = currentAngle + angleSize / 2;
const labelRadius = radius * 0.70;
const labelX = pieCenterX + labelRadius * Math.cos((midAngle - 90) * Math.PI / 180) - 3;
const labelRadius = radius * 0.65;
const labelX = pieCenterX + labelRadius * Math.cos((midAngle - 90) * Math.PI / 180);
const labelY = pieCenterY + labelRadius * Math.sin((midAngle - 90) * Math.PI / 180);
const percentLabel = percentage > 0.05 ? // 5% 이상만 표시
`<text x="${labelX}" y="${labelY}" fill="white" text-anchor="middle" class="percentage-label">${Math.round(percentage * 100)}%</text>` : '';
const percentLabel = percentage > 0.08 ?
`<text x="${labelX}" y="${labelY}" fill="white" text-anchor="middle" dominant-baseline="middle" class="percentage-label">${Math.round(percentage * 100)}%</text>` : '';

pieChart += path + percentLabel;
currentAngle = endAngle;
});

// 범례 생성 (상위 카테고리만)
topCategories.forEach((category, index) => {
const legendY = legendStartY + (index * legendItemHeight);
const percentage = Math.round((category.score / totalCategoryScore) * 100);
const capitalizedName = category.name.charAt(0).toUpperCase() + category.name.slice(1);
legends += `
<rect x="${legendStartX}" y="${legendY}" width="12" height="12" fill="${category.color}" rx="2" ry="2" />
<text x="${legendStartX + 20}" y="${legendY + 10}" class="legend-text">${category.name}: ${category.score}</text>
<rect x="${legendStartX}" y="${legendY}" width="10" height="10" fill="${category.color}" rx="2" ry="2" />
<text x="${legendStartX + 16}" y="${legendY + 9}" class="legend-text">${capitalizedName}</text>
<text x="${legendStartX + 135}" y="${legendY + 9}" class="legend-value">${percentage}%</text>
`;
});
}

// 중앙 총점 표시 (원형 차트 내부에)
const centerCircle = `
<circle cx="${pieCenterX}" cy="${pieCenterY}" r="${radius * 0.4}" fill="white" />
<text x="${pieCenterX}" y="${pieCenterY - 5}" text-anchor="middle" class="total-score-label">Score</text>
<text x="${pieCenterX}" y="${pieCenterY + 10}" text-anchor="middle" class="total-score-value">${totalScore}</text>
`;

// 제목 (오른쪽 상단)
const header = `
<text x="${legendStartX-5}" y="35" class="title" text-anchor="start">Dreamhack</text>
<text x="${legendStartX-5}" y="55" class="title" text-anchor="start">Most solved categories</text>
<circle cx="${pieCenterX}" cy="${pieCenterY}" r="${radius * 0.45}" fill="white" />
<text x="${pieCenterX}" y="${pieCenterY - 6}" text-anchor="middle" class="total-score-label">Total</text>
<text x="${pieCenterX}" y="${pieCenterY + 12}" text-anchor="middle" class="total-score-value">${totalScore}</text>
`;

// SVG 구조 반환
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" fill="none">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&amp;display=swap');
.title { font: 700 16px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill:rgb(9, 112, 201); }
.user-name { font: 800 32px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill: #333; }
.legend-text { font: 400 17px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill: #444; }
.percentage-label { font: 600 10px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill: white; text-shadow: 0 0 2px rgba(0,0,0,0.5); }
.total-score-label { font: 500 10px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill: #777; }
.total-score-value { font: 700 14px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill: #6e45e2; }
.no-data { font: 500 12px 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; fill: #888; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&amp;display=swap');
.title { font: 500 13px 'JetBrains Mono', monospace; fill: #64748b; }
.legend-text { font: 500 13px 'JetBrains Mono', monospace; fill: #0f172a; }
.legend-value { font: 700 13px 'JetBrains Mono', monospace; fill: #64748b; }
.percentage-label { font: 700 10px 'JetBrains Mono', monospace; fill: white; }
.total-score-label { font: 500 10px 'JetBrains Mono', monospace; fill: #64748b; }
.total-score-value { font: 700 16px 'JetBrains Mono', monospace; fill: #3b82f6; }
.no-data { font: 500 12px 'JetBrains Mono', monospace; fill: #94a3b8; }
</style>

<rect x="0.5" y="0.5" width="${width-1}" height="${height-1}" fill="#f8faff" rx="12" ry="12" stroke="#333" stroke-width="0.5" />

<!-- 제목 (오른쪽 상단) -->
${header}

<!-- 원형 차트 (왼쪽) -->
<!-- 배경 -->
<rect width="${width}" height="${height}" fill="#ffffff" rx="12" ry="12"/>

<!-- 타이틀 -->
<text x="20" y="30" class="title">Most Solved Categories</text>

<!-- 범례 배경 -->
<rect x="15" y="55" width="175" height="120" fill="#f8fafc" rx="8" ry="8"/>

<!-- 범례 -->
<g transform="translate(0, 0)">
${pieChart}
${centerCircle}
${legends}
</g>

<!-- 범례 (오른쪽) -->
<!-- 원형 차트 -->
<g transform="translate(0, 0)">
${legends}
${pieChart}
${centerCircle}
</g>

<!-- 테두리 -->
<rect x="0.5" y="0.5" width="${width-1}" height="${height-1}" fill="none" stroke="#e2e8f0" stroke-width="1" rx="12" ry="12"/>
</svg>
`;
}
Loading