From 26960ed53f87abdc3635fc6b74b7757ed3edc7f2 Mon Sep 17 00:00:00 2001 From: harry Date: Fri, 4 Apr 2025 17:00:00 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20TuserData=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=97=90=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TuserData 인터페이스에 pwnable, reversing, web, crypto 카테고리 추가 - 각 카테고리에 대해 점수와 순위를 포함하여 데이터 구조 확장 --- src/types/index.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index 031f974..14f3a3c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,28 @@ export interface TuserData { solved: number; rank: number; score: number; + category: { + pwnable?: { + score: number; + rank: number; + }; + reversing?: { + score: number; + rank: number; + }; + web?: { + score: number; + rank: number; + }; + crypto?: { + score: number; + rank: number; + }; + [key: string]: { + score: number; + rank: number; + } | undefined; + }; }; ctf: { rank: number; From 260e65d3c0e36d6e1966c6920b44ffa4b21d3b36 Mon Sep 17 00:00:00 2001 From: harry Date: Fri, 25 Apr 2025 15:49:23 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20Most=20solved=20categories=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md에 Dreamhack 카테고리 차트 섹션 추가 및 사용 방법 설명 - index.tsx에 카테고리 차트 예시 및 코드 스니펫 복사 기능 구현 - 새로운 API 엔드포인트 most-solved.ts 추가하여 사용자별 카테고리 점수 및 순위 제공 - generateCategorySvg.ts 파일 추가하여 SVG 형식의 카테고리 차트 생성 기능 구현 - 스타일 개선: Home.module.css에서 예시 마진 조정 --- README.md | 22 ++++++ src/pages/api/most-solved.ts | 114 ++++++++++++++++++++++++++++ src/pages/index.tsx | 67 +++++++++++++++++ src/styles/Home.module.css | 2 +- src/types/index.ts | 13 ++++ src/utils/generateCategorySvg.ts | 125 +++++++++++++++++++++++++++++++ 6 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/pages/api/most-solved.ts create mode 100644 src/utils/generateCategorySvg.ts diff --git a/README.md b/README.md index 44a0857..ca8e8a4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,28 @@ HTML 코드: ``` +## 카테고리 차트 + +Dreamhack의 워게임 카테고리별 점수를 원형 차트로 표시합니다. 각 카테고리별 점수와 랭킹을 확인할 수 있습니다. + +### 사용 방법 + +#### Markdown +```markdown +![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명) +``` + +#### HTML +```html + + Dreamhack Category Chart + +``` + +### 예시 + +![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness) + ## 기술 스택 - Next.js diff --git a/src/pages/api/most-solved.ts b/src/pages/api/most-solved.ts new file mode 100644 index 0000000..426163b --- /dev/null +++ b/src/pages/api/most-solved.ts @@ -0,0 +1,114 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { TCategoryData, TCategoryStats } from '../../types'; +import { generateCategorySvg } from '../../utils/generateCategorySvg'; +import { getUserId, getUserData } from '../../utils/dreamhack'; + +// 성능 측정을 위한 유틸리티 함수 +const measureTime = async (name: string, fn: () => Promise): Promise => { + console.time(`⏱️ ${name}`); + try { + return await fn(); + } finally { + console.timeEnd(`⏱️ ${name}`); + } +}; + +// 카테고리별 색상 매핑 +const categoryColors: Record = { + web: '#ff6b6b', // 빨간색 계열 + pwnable: '#339af0', // 파란색 계열 + reversing: '#51cf66', // 초록색 계열 + crypto: '#fcc419', // 노랑색 계열 + forensic: '#cc5de8', // 보라색 계열 + misc: '#20c997', // 청록색 계열 +}; + +// 기본 색상 팔레트 (알 수 없는 카테고리용) +const defaultColors = [ + '#74c0fc', '#a5d8ff', '#66d9e8', '#63e6be', '#8ce99a', + '#b2f2bb', '#d8f5a2', '#ffec99', '#ffc078', '#ffa8a8' +]; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + console.time('⏱️ 전체 API 실행 시간'); + const { username } = req.query; + + if (!username || typeof username !== 'string') { + return res.status(400).json({ error: 'Username is required' }); + } + + try { + // 개별 API 호출 시간 측정 + const userId = await measureTime('getUserId', () => getUserId(username as string)); + + if (!userId) { + console.timeEnd('⏱️ 전체 API 실행 시간'); + return res.status(400).json({ error: 'User not found' }); + } + + // username을 함께 전달하여 캐시된 ID가 유효하지 않을 경우 재시도할 수 있도록 함 + const userData = await measureTime('getUserData', () => getUserData(userId, username as string)); + if (!userData) { + console.timeEnd('⏱️ 전체 API 실행 시간'); + return res.status(400).json({ error: 'User information cannot be read.' }); + } + + console.time('⏱️ 데이터 가공 및 SVG 생성'); + const { + nickname, + wargame + } = userData; + + // 카테고리 데이터 추출 및 가공 + const categoryEntries = Object.entries(wargame.category || {}); + + // 카테고리가 없는 경우 빈 배열로 처리 + if (categoryEntries.length === 0) { + const emptyStats: TCategoryStats = { + nickname, + total_score: wargame.score || 0, + categories: [] + }; + + const svg = generateCategorySvg(emptyStats); + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + console.timeEnd('⏱️ 전체 API 실행 시간'); + return res.send(svg); + } + + // 카테고리 데이터가 있는 경우 가공 + const categories: TCategoryData[] = categoryEntries.map(([name, data], index) => { + // 카테고리에 해당하는 색상 선택 (미리 정의된 색상이 없으면 기본 팔레트에서 선택) + const color = categoryColors[name] || defaultColors[index % defaultColors.length]; + + return { + name, + score: data?.score || 0, + rank: data?.rank || 0, + color + }; + }); + + // 점수가 높은 순서로 정렬 + categories.sort((a, b) => b.score - a.score); + + const categoryStats: TCategoryStats = { + nickname, + total_score: wargame.score || 0, + categories + }; + + const svg = generateCategorySvg(categoryStats); + console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); + + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + console.timeEnd('⏱️ 전체 API 실행 시간'); + return res.send(svg); + } catch (error) { + console.error(error); + console.timeEnd('⏱️ 전체 API 실행 시간'); + return res.status(500).json({ error: 'Internal Server Error' }); + } +} \ No newline at end of file diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ffea399..016b68b 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -75,6 +75,7 @@ export default function Home() { +

예시:

+ + Dreamhack category chart example + +
+ +
+

카테고리 차트:

+
+
+

Markdown:

+ +
+ + ![Dreamhack Category Chart](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=사용자명) + +
+
+
+

HTML:

+ +
+ + {` + Dreamhack Category Chart +`} + +
+
+ +
+

카테고리 차트 예시:

+ + Dreamhack category chart example +

diff --git a/src/styles/Home.module.css b/src/styles/Home.module.css index 5802e98..977dd08 100644 --- a/src/styles/Home.module.css +++ b/src/styles/Home.module.css @@ -88,7 +88,7 @@ } .example { - margin: 2rem 0; + margin: 0 0 2rem 0; text-align: center; } diff --git a/src/types/index.ts b/src/types/index.ts index 14f3a3c..d754792 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -60,4 +60,17 @@ export interface TUserRankingResponse { export interface TUserRankingResult { id: number; nickname: string; +} + +export interface TCategoryData { + name: string; + score: number; + rank: number; + color: string; +} + +export interface TCategoryStats { + nickname: string; + total_score: number; + categories: TCategoryData[]; } \ No newline at end of file diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts new file mode 100644 index 0000000..8b1873d --- /dev/null +++ b/src/utils/generateCategorySvg.ts @@ -0,0 +1,125 @@ +import { TCategoryStats } from '../types'; + +export function generateCategorySvg(stats: TCategoryStats): string { + // SVG 크기 및 차트 설정 + const width = 390; + const height = 190; + const pieCenterX = 95; // 원형 차트 중심 X (왼쪽) + const pieCenterY = 96; // 원형 차트 중심 Y + const radius = 88; // 원형 차트 반지름 + + // 전체 스코어 + 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 = 230; // 범례 시작 X 위치 (오른쪽) + const legendStartY = 80; // 범례 시작 Y 위치 (제목 아래) + const legendItemHeight = 22; // 범례 항목 간 간격 + const maxLegendItems = 5; // 표시할 최대 범례 항목 수 + + // 카테고리가 없는 경우 + if (categories.length === 0 || totalCategoryScore === 0) { + pieChart = ` + No data`; + legends = `No category data`; + } 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 = ` + + `; + + // 퍼센트 라벨 + const midAngle = currentAngle + angleSize / 2; + const labelRadius = radius * 0.70; + const labelX = pieCenterX + labelRadius * Math.cos((midAngle - 90) * Math.PI / 180) - 3; + const labelY = pieCenterY + labelRadius * Math.sin((midAngle - 90) * Math.PI / 180); + const percentLabel = percentage > 0.05 ? // 5% 이상만 표시 + `${Math.round(percentage * 100)}%` : ''; + + pieChart += path + percentLabel; + currentAngle = endAngle; + }); + + // 범례 생성 (상위 카테고리만) + topCategories.forEach((category, index) => { + const legendY = legendStartY + (index * legendItemHeight); + legends += ` + + ${category.name}: ${category.score} + `; + }); + } + + // 중앙 총점 표시 (원형 차트 내부에) + const centerCircle = ` + + Score + ${totalScore} + `; + + // 제목 (오른쪽 상단) + const header = ` + Dreamhack + Most solved categories + `; + + // SVG 구조 반환 + return ` + + + + + + + ${header} + + + + ${pieChart} + ${centerCircle} + + + + + ${legends} + + + + `; +} \ No newline at end of file From 9b56e326b9114afd95e8dc321f1d7689c895914f Mon Sep 17 00:00:00 2001 From: harry Date: Fri, 25 Apr 2025 15:54:54 +0900 Subject: [PATCH 03/10] =?UTF-8?q?chore:=20index.ts=EC=97=90=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=90=9C=20svg=20=EC=98=88=EC=8B=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/index.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 016b68b..56e1607 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -91,19 +91,6 @@ export default function Home() { height={170} /> - - Dreamhack category chart example -
From f848c2c066e18ebe9d47a14141d1051beba6c06c Mon Sep 17 00:00:00 2001 From: harry Date: Fri, 25 Apr 2025 16:34:25 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20wargame=20stats=20svg(generateSvg?= =?UTF-8?q?.ts)=EC=97=90=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/generateSvg.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/generateSvg.ts b/src/utils/generateSvg.ts index b943c9a..0f0c543 100644 --- a/src/utils/generateSvg.ts +++ b/src/utils/generateSvg.ts @@ -29,9 +29,11 @@ export function generateSvg(stats: Tstats): string { - + + + TOP ${stats.wargameRankPercentage}% @@ -51,7 +53,8 @@ export function generateSvg(stats: Tstats): string { Score ${stats.wargame_score} - + + `; } \ No newline at end of file From bdbfccf948bec9919365c61c038a6617fee086de Mon Sep 17 00:00:00 2001 From: harry Date: Fri, 25 Apr 2025 16:43:33 +0900 Subject: [PATCH 05/10] =?UTF-8?q?chore:=20generateSvg=EB=A5=BC=20generateS?= =?UTF-8?q?tatsSvg=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20SVG?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generateSvg를 generateStatsSvg로 변경하여 SVG 생성 로직 개선 - stats API에서 새로운 SVG 생성 함수 사용 - generateStatsSvg에 대한 테스트 케이스 추가 --- src/__tests__/pages/api/stats.test.ts | 12 ++++++------ ...{generateSvg.test.ts => generateStatsSvg.test.ts} | 10 +++++----- src/pages/api/stats.ts | 4 ++-- src/utils/{generateSvg.ts => generateStatsSvg.ts} | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) rename src/__tests__/utils/{generateSvg.test.ts => generateStatsSvg.test.ts} (85%) rename src/utils/{generateSvg.ts => generateStatsSvg.ts} (97%) diff --git a/src/__tests__/pages/api/stats.test.ts b/src/__tests__/pages/api/stats.test.ts index bac1ed6..2cf53cd 100644 --- a/src/__tests__/pages/api/stats.test.ts +++ b/src/__tests__/pages/api/stats.test.ts @@ -1,7 +1,7 @@ import { createMocks } from 'node-mocks-http'; import handler from '../../../pages/api/stats'; import * as dreamhackUtils from '../../../utils/dreamhack'; -import * as generateSvgUtils from '../../../utils/generateSvg'; +import * as generateSvgUtils from '../../../utils/generateStatsSvg'; import { TuserData } from '../../../types'; // dreamhack 유틸리티 함수 모킹 @@ -12,9 +12,9 @@ jest.mock('../../../utils/dreamhack', () => ({ calculateTopPercentage: jest.fn(), })); -// generateSvg 유틸리티 함수 모킹 -jest.mock('../../../utils/generateSvg', () => ({ - generateSvg: jest.fn(), +// generateStatsSvg 유틸리티 함수 모킹 +jest.mock('../../../utils/generateStatsSvg', () => ({ + generateStatsSvg: jest.fn(), })); describe('stats API 엔드포인트 테스트', () => { @@ -45,7 +45,7 @@ describe('stats API 엔드포인트 테스트', () => { (dreamhackUtils.getLastRank as jest.Mock).mockResolvedValueOnce(mockLastRank); (dreamhackUtils.getUserData as jest.Mock).mockResolvedValueOnce(mockUserData); (dreamhackUtils.calculateTopPercentage as jest.Mock).mockReturnValueOnce('20.00'); - (generateSvgUtils.generateSvg as jest.Mock).mockReturnValueOnce(mockSvg); + (generateSvgUtils.generateStatsSvg as jest.Mock).mockReturnValueOnce(mockSvg); // HTTP 요청 모킹 const { req, res } = createMocks({ @@ -69,7 +69,7 @@ describe('stats API 엔드포인트 테스트', () => { expect(dreamhackUtils.getLastRank).toHaveBeenCalled(); expect(dreamhackUtils.getUserData).toHaveBeenCalledWith(mockUserId, 'weakness'); expect(dreamhackUtils.calculateTopPercentage).toHaveBeenCalledWith(mockUserData.wargame.rank, mockLastRank); - expect(generateSvgUtils.generateSvg).toHaveBeenCalledWith({ + expect(generateSvgUtils.generateStatsSvg).toHaveBeenCalledWith({ nickname: mockUserData.nickname, wargame_solved: mockUserData.total_wargame, wargame_rank: `${mockUserData.wargame.rank}/${mockLastRank}`, diff --git a/src/__tests__/utils/generateSvg.test.ts b/src/__tests__/utils/generateStatsSvg.test.ts similarity index 85% rename from src/__tests__/utils/generateSvg.test.ts rename to src/__tests__/utils/generateStatsSvg.test.ts index 8efd49d..06d42a9 100644 --- a/src/__tests__/utils/generateSvg.test.ts +++ b/src/__tests__/utils/generateStatsSvg.test.ts @@ -1,7 +1,7 @@ -import { generateSvg } from '../../utils/generateSvg'; +import { generateStatsSvg } from '../../utils/generateStatsSvg'; import { Tstats } from '../../types'; -describe('generateSvg 유틸리티 함수 테스트', () => { +describe('generateStatsSvg 유틸리티 함수 테스트', () => { it('유효한 통계 데이터로 SVG를 생성해야 함', () => { const mockStats: Tstats = { nickname: 'testuser', @@ -11,7 +11,7 @@ describe('generateSvg 유틸리티 함수 테스트', () => { wargame_score: 5000 }; - const result = generateSvg(mockStats); + const result = generateStatsSvg(mockStats); // SVG 문자열이 반환되었는지 확인 expect(result).toContain(' { wargame_score: 5000 }; - const result = generateSvg(mockStats); + const result = generateStatsSvg(mockStats); // 특수 문자가 포함된 사용자 이름이 SVG에 포함되어 있는지 확인 expect(result).toContain('test'); @@ -49,7 +49,7 @@ describe('generateSvg 유틸리티 함수 테스트', () => { wargame_score: 5000 }; - const result = generateSvg(mockStats); + const result = generateStatsSvg(mockStats); // 긴 사용자 이름이 SVG에 포함되어 있는지 확인 expect(result).toContain('verylongusernamethatmightcauseissueswithsvgrendering'); diff --git a/src/pages/api/stats.ts b/src/pages/api/stats.ts index 7de3f30..2d9c569 100644 --- a/src/pages/api/stats.ts +++ b/src/pages/api/stats.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { Tstats } from '../../types'; -import { generateSvg } from '../../utils/generateSvg'; +import { generateStatsSvg } from '../../utils/generateStatsSvg'; import { getLastRank, calculateTopPercentage, @@ -64,7 +64,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) wargame_score: wargame.score, }; - const svg = generateSvg(stats); + const svg = generateStatsSvg(stats); console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); res.setHeader('Content-Type', 'image/svg+xml'); diff --git a/src/utils/generateSvg.ts b/src/utils/generateStatsSvg.ts similarity index 97% rename from src/utils/generateSvg.ts rename to src/utils/generateStatsSvg.ts index 0f0c543..762c6ce 100644 --- a/src/utils/generateSvg.ts +++ b/src/utils/generateStatsSvg.ts @@ -1,6 +1,6 @@ import { Tstats } from '../types'; -export function generateSvg(stats: Tstats): string { +export function generateStatsSvg(stats: Tstats): string { return ` Dreamhack category chart example Date: Thu, 19 Jun 2025 15:31:18 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20generateCategorySvg=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=9D=98=20SVG=20=EC=9A=94=EC=86=8C=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원형 차트 중심 X 위치 및 범례 시작 X 위치 조정 - 제목 텍스트 위치 수정 및 스타일 변경 - 배경 색상 및 범례 텍스트 스타일 개선 --- src/utils/generateCategorySvg.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts index 8b1873d..1842adc 100644 --- a/src/utils/generateCategorySvg.ts +++ b/src/utils/generateCategorySvg.ts @@ -4,7 +4,7 @@ export function generateCategorySvg(stats: TCategoryStats): string { // SVG 크기 및 차트 설정 const width = 390; const height = 190; - const pieCenterX = 95; // 원형 차트 중심 X (왼쪽) + const pieCenterX = 290; // 원형 차트 중심 X (왼쪽) const pieCenterY = 96; // 원형 차트 중심 Y const radius = 88; // 원형 차트 반지름 @@ -20,7 +20,7 @@ export function generateCategorySvg(stats: TCategoryStats): string { let legends = ''; let currentAngle = 0; - const legendStartX = 230; // 범례 시작 X 위치 (오른쪽) + const legendStartX = 25; // 범례 시작 X 위치 (오른쪽) const legendStartY = 80; // 범례 시작 Y 위치 (제목 아래) const legendItemHeight = 22; // 범례 항목 간 간격 const maxLegendItems = 5; // 표시할 최대 범례 항목 수 @@ -86,8 +86,8 @@ export function generateCategorySvg(stats: TCategoryStats): string { // 제목 (오른쪽 상단) const header = ` - Dreamhack - Most solved categories + Dreamhack + Most solved categories `; // SVG 구조 반환 @@ -95,16 +95,16 @@ export function generateCategorySvg(stats: TCategoryStats): string { - + ${header}