Skip to content

Commit ff9839b

Browse files
authored
Top languages card pie layout (#2709)
* Top languages card donut layout * Top languages card pie layout * renames * dev * docs * dev * dev * animations * dev * handle one language
1 parent 1f4a2c4 commit ff9839b

File tree

4 files changed

+250
-6
lines changed

4 files changed

+250
-6
lines changed

readme.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,14 @@ You can use the `&layout=donut` option to change the card design.
431431
[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=donut)](https://github.com/anuraghazra/github-readme-stats)
432432
```
433433

434+
### Pie Chart Language Card Layout
435+
436+
You can use the `&layout=pie` option to change the card design.
437+
438+
```md
439+
[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=pie)](https://github.com/anuraghazra/github-readme-stats)
440+
```
441+
434442
### Hide Progress Bars
435443

436444
You can use the `&hide_progress=true` option to hide the percentages and the progress bars (layout will be automatically set to `compact`).
@@ -451,6 +459,10 @@ You can use the `&hide_progress=true` option to hide the percentages and the pro
451459

452460
[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=donut)](https://github.com/anuraghazra/github-readme-stats)
453461

462+
- Pie Chart layout
463+
464+
[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=pie)](https://github.com/anuraghazra/github-readme-stats)
465+
454466
- Hidden progress bars
455467

456468
[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats)

src/cards/top-languages-card.js

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ const calculateDonutLayoutHeight = (totalLangs) => {
114114
return 215 + Math.max(totalLangs - 5, 0) * 32;
115115
};
116116

117+
/**
118+
* Calculates height for the pie layout.
119+
*
120+
* @param {number} totalLangs Total number of languages.
121+
* @returns {number} Card height.
122+
*/
123+
const calculatePieLayoutHeight = (totalLangs) => {
124+
return 300 + Math.round(totalLangs / 2) * 25;
125+
};
126+
117127
/**
118128
* Calculates the center translation needed to keep the donut chart centred.
119129
* @param {number} totalLangs Total number of languages.
@@ -361,6 +371,101 @@ const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => {
361371
`;
362372
};
363373

374+
/**
375+
* Renders pie layout to display user's most frequently used programming languages.
376+
*
377+
* @param {Lang[]} langs Array of programming languages.
378+
* @param {number} totalLanguageSize Total size of all languages.
379+
* @returns {string} Compact layout card SVG object.
380+
*/
381+
const renderPieLayout = (langs, totalLanguageSize) => {
382+
// Pie chart radius and center coordinates
383+
const radius = 90;
384+
const centerX = 150;
385+
const centerY = 100;
386+
387+
// Start angle for the pie chart parts
388+
let startAngle = 0;
389+
390+
// Start delay coefficient for the pie chart parts
391+
let startDelayCoefficient = 1;
392+
393+
// SVG paths
394+
const paths = [];
395+
396+
// Generate each pie chart part
397+
for (const lang of langs) {
398+
if (langs.length === 1) {
399+
paths.push(`
400+
<circle
401+
cx="${centerX}"
402+
cy="${centerY}"
403+
r="${radius}"
404+
stroke="none"
405+
fill="${lang.color}"
406+
data-testid="lang-pie"
407+
size="100"
408+
/>
409+
`);
410+
break;
411+
}
412+
413+
const langSizePart = lang.size / totalLanguageSize;
414+
const percentage = langSizePart * 100;
415+
// Calculate the angle for the current part
416+
const angle = langSizePart * 360;
417+
418+
// Calculate the end angle
419+
const endAngle = startAngle + angle;
420+
421+
// Calculate the coordinates of the start and end points of the arc
422+
const startPoint = polarToCartesian(centerX, centerY, radius, startAngle);
423+
const endPoint = polarToCartesian(centerX, centerY, radius, endAngle);
424+
425+
// Determine the large arc flag based on the angle
426+
const largeArcFlag = angle > 180 ? 1 : 0;
427+
428+
// Calculate delay
429+
const delay = startDelayCoefficient * 100;
430+
431+
// SVG arc markup
432+
paths.push(`
433+
<g class="stagger" style="animation-delay: ${delay}ms">
434+
<path
435+
data-testid="lang-pie"
436+
size="${percentage}"
437+
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
438+
fill="${lang.color}"
439+
/>
440+
</g>
441+
`);
442+
443+
// Update the start angle for the next part
444+
startAngle = endAngle;
445+
// Update the start delay coefficient for the next part
446+
startDelayCoefficient += 1;
447+
}
448+
449+
return `
450+
<svg data-testid="lang-items">
451+
<g transform="translate(0, 0)">
452+
<svg data-testid="pie">
453+
${paths.join("")}
454+
</svg>
455+
</g>
456+
<g transform="translate(0, 220)">
457+
<svg data-testid="lang-names" x="${CARD_PADDING}">
458+
${createLanguageTextNode({
459+
langs,
460+
totalSize: totalLanguageSize,
461+
hideProgress: false,
462+
})}
463+
</svg>
464+
</g>
465+
</svg>
466+
`;
467+
};
468+
364469
/**
365470
* Creates the SVG paths for the language donut chart.
366471
*
@@ -505,7 +610,10 @@ const renderTopLanguages = (topLangs, options = {}) => {
505610
let height = calculateNormalLayoutHeight(langs.length);
506611

507612
let finalLayout = "";
508-
if (layout === "compact" || hide_progress == true) {
613+
if (layout === "pie") {
614+
height = calculatePieLayoutHeight(langs.length);
615+
finalLayout = renderPieLayout(langs, totalLanguageSize);
616+
} else if (layout === "compact" || hide_progress == true) {
509617
height =
510618
calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);
511619

@@ -580,6 +688,10 @@ const renderTopLanguages = (topLangs, options = {}) => {
580688
`,
581689
);
582690

691+
if (layout === "pie") {
692+
return card.render(finalLayout);
693+
}
694+
583695
return card.render(`
584696
<svg data-testid="lang-items" x="${CARD_PADDING}">
585697
${finalLayout}
@@ -596,6 +708,7 @@ export {
596708
calculateCompactLayoutHeight,
597709
calculateNormalLayoutHeight,
598710
calculateDonutLayoutHeight,
711+
calculatePieLayoutHeight,
599712
donutCenterTranslation,
600713
trimTopLanguages,
601714
renderTopLanguages,

src/cards/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type TopLangOptions = CommonOptions & {
3939
hide_border: boolean;
4040
card_width: number;
4141
hide: string[];
42-
layout: "compact" | "normal" | "donut";
42+
layout: "compact" | "normal" | "donut" | "pie";
4343
custom_title: string;
4444
langs_count: number;
4545
disable_animations: boolean;

tests/renderTopLanguages.test.js

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
calculateCompactLayoutHeight,
1010
calculateNormalLayoutHeight,
1111
calculateDonutLayoutHeight,
12+
calculatePieLayoutHeight,
1213
donutCenterTranslation,
1314
trimTopLanguages,
1415
renderTopLanguages,
@@ -40,12 +41,13 @@ const langs = {
4041

4142
/**
4243
* Retrieve the language percentage from the donut chart SVG.
44+
*
4345
* @param {string} d The SVG path element.
4446
* @param {number} centerX The center X coordinate of the donut chart.
4547
* @param {number} centerY The center Y coordinate of the donut chart.
4648
* @returns {number} The percentage of the language.
4749
*/
48-
const langPercentFromSvg = (d, centerX, centerY) => {
50+
const langPercentFromDonutLayoutSvg = (d, centerX, centerY) => {
4951
const dTmp = d
5052
.split(" ")
5153
.filter((x) => !isNaN(x))
@@ -58,6 +60,34 @@ const langPercentFromSvg = (d, centerX, centerY) => {
5860
return (endAngle - startAngle) / 3.6;
5961
};
6062

63+
/**
64+
* Retrieve the language percentage from the pie chart SVG.
65+
*
66+
* @param {string} d The SVG path element.
67+
* @param {number} centerX The center X coordinate of the pie chart.
68+
* @param {number} centerY The center Y coordinate of the pie chart.
69+
* @returns {number} The percentage of the language.
70+
*/
71+
const langPercentFromPieLayoutSvg = (d, centerX, centerY) => {
72+
const dTmp = d
73+
.split(" ")
74+
.filter((x) => !isNaN(x))
75+
.map((x) => parseFloat(x));
76+
const startAngle = cartesianToPolar(
77+
centerX,
78+
centerY,
79+
dTmp[2],
80+
dTmp[3],
81+
).angleInDegrees;
82+
let endAngle = cartesianToPolar(
83+
centerX,
84+
centerY,
85+
dTmp[9],
86+
dTmp[10],
87+
).angleInDegrees;
88+
return ((endAngle - startAngle) / 360) * 100;
89+
};
90+
6191
describe("Test renderTopLanguages helper functions", () => {
6292
it("getLongestLang", () => {
6393
const langArray = Object.values(langs);
@@ -193,6 +223,20 @@ describe("Test renderTopLanguages helper functions", () => {
193223
expect(calculateDonutLayoutHeight(10)).toBe(375);
194224
});
195225

226+
it("calculatePieLayoutHeight", () => {
227+
expect(calculatePieLayoutHeight(0)).toBe(300);
228+
expect(calculatePieLayoutHeight(1)).toBe(325);
229+
expect(calculatePieLayoutHeight(2)).toBe(325);
230+
expect(calculatePieLayoutHeight(3)).toBe(350);
231+
expect(calculatePieLayoutHeight(4)).toBe(350);
232+
expect(calculatePieLayoutHeight(5)).toBe(375);
233+
expect(calculatePieLayoutHeight(6)).toBe(375);
234+
expect(calculatePieLayoutHeight(7)).toBe(400);
235+
expect(calculatePieLayoutHeight(8)).toBe(400);
236+
expect(calculatePieLayoutHeight(9)).toBe(425);
237+
expect(calculatePieLayoutHeight(10)).toBe(425);
238+
});
239+
196240
it("donutCenterTranslation", () => {
197241
expect(donutCenterTranslation(0)).toBe(-45);
198242
expect(donutCenterTranslation(1)).toBe(-45);
@@ -466,7 +510,7 @@ describe("Test renderTopLanguages", () => {
466510
.filter((x) => !isNaN(x))
467511
.map((x) => parseFloat(x));
468512
const center = { x: d[7], y: d[7] };
469-
const HTMLLangPercent = langPercentFromSvg(
513+
const HTMLLangPercent = langPercentFromDonutLayoutSvg(
470514
queryAllByTestId(document.body, "lang-donut")[0].getAttribute("d"),
471515
center.x,
472516
center.y,
@@ -480,7 +524,7 @@ describe("Test renderTopLanguages", () => {
480524
"size",
481525
"40",
482526
);
483-
const javascriptLangPercent = langPercentFromSvg(
527+
const javascriptLangPercent = langPercentFromDonutLayoutSvg(
484528
queryAllByTestId(document.body, "lang-donut")[1].getAttribute("d"),
485529
center.x,
486530
center.y,
@@ -494,7 +538,7 @@ describe("Test renderTopLanguages", () => {
494538
"size",
495539
"20",
496540
);
497-
const cssLangPercent = langPercentFromSvg(
541+
const cssLangPercent = langPercentFromDonutLayoutSvg(
498542
queryAllByTestId(document.body, "lang-donut")[2].getAttribute("d"),
499543
center.x,
500544
center.y,
@@ -520,6 +564,81 @@ describe("Test renderTopLanguages", () => {
520564
"circle",
521565
);
522566
});
567+
it("should render with layout pie", () => {
568+
document.body.innerHTML = renderTopLanguages(langs, { layout: "pie" });
569+
570+
expect(queryByTestId(document.body, "header")).toHaveTextContent(
571+
"Most Used Languages",
572+
);
573+
574+
expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
575+
"HTML 40.00%",
576+
);
577+
expect(queryAllByTestId(document.body, "lang-pie")[0]).toHaveAttribute(
578+
"size",
579+
"40",
580+
);
581+
582+
const d = queryAllByTestId(document.body, "lang-pie")[0]
583+
.getAttribute("d")
584+
.split(" ")
585+
.filter((x) => !isNaN(x))
586+
.map((x) => parseFloat(x));
587+
const center = { x: d[0], y: d[1] };
588+
const HTMLLangPercent = langPercentFromPieLayoutSvg(
589+
queryAllByTestId(document.body, "lang-pie")[0].getAttribute("d"),
590+
center.x,
591+
center.y,
592+
);
593+
expect(HTMLLangPercent).toBeCloseTo(40);
594+
595+
expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent(
596+
"javascript 40.00%",
597+
);
598+
expect(queryAllByTestId(document.body, "lang-pie")[1]).toHaveAttribute(
599+
"size",
600+
"40",
601+
);
602+
const javascriptLangPercent = langPercentFromPieLayoutSvg(
603+
queryAllByTestId(document.body, "lang-pie")[1].getAttribute("d"),
604+
center.x,
605+
center.y,
606+
);
607+
expect(javascriptLangPercent).toBeCloseTo(40);
608+
609+
expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent(
610+
"css 20.00%",
611+
);
612+
expect(queryAllByTestId(document.body, "lang-pie")[2]).toHaveAttribute(
613+
"size",
614+
"20",
615+
);
616+
const cssLangPercent = langPercentFromPieLayoutSvg(
617+
queryAllByTestId(document.body, "lang-pie")[2].getAttribute("d"),
618+
center.x,
619+
center.y,
620+
);
621+
expect(cssLangPercent).toBeCloseTo(20);
622+
623+
expect(HTMLLangPercent + javascriptLangPercent + cssLangPercent).toBe(100);
624+
625+
// Should render full pie (circle) if one language is 100%.
626+
document.body.innerHTML = renderTopLanguages(
627+
{ HTML: langs.HTML },
628+
{ layout: "pie" },
629+
);
630+
expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
631+
"HTML 100.00%",
632+
);
633+
expect(queryAllByTestId(document.body, "lang-pie")[0]).toHaveAttribute(
634+
"size",
635+
"100",
636+
);
637+
expect(queryAllByTestId(document.body, "lang-pie")).toHaveLength(1);
638+
expect(queryAllByTestId(document.body, "lang-pie")[0].tagName).toBe(
639+
"circle",
640+
);
641+
});
523642

524643
it("should render a translated title", () => {
525644
document.body.innerHTML = renderTopLanguages(langs, { locale: "cn" });

0 commit comments

Comments
 (0)