Skip to content
12 changes: 12 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,14 @@ You can use the `&layout=donut` option to change the card design.
[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=donut)](https://github.com/anuraghazra/github-readme-stats)
```

### Pie Chart Language Card Layout

You can use the `&layout=pie` option to change the card design.

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

### Hide Progress Bars

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

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

- Pie Chart layout

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

- Hidden progress bars

[![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats)
Expand Down
115 changes: 114 additions & 1 deletion src/cards/top-languages-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ const calculateDonutLayoutHeight = (totalLangs) => {
return 215 + Math.max(totalLangs - 5, 0) * 32;
};

/**
* Calculates height for the pie layout.
*
* @param {number} totalLangs Total number of languages.
* @returns {number} Card height.
*/
const calculatePieLayoutHeight = (totalLangs) => {
return 300 + Math.round(totalLangs / 2) * 25;
};

/**
* Calculates the center translation needed to keep the donut chart centred.
* @param {number} totalLangs Total number of languages.
Expand Down Expand Up @@ -360,6 +370,101 @@ const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => {
`;
};

/**
* Renders pie layout to display user's most frequently used programming languages.
*
* @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages.
* @returns {string} Compact layout card SVG object.
*/
const renderPieLayout = (langs, totalLanguageSize) => {
// Pie chart radius and center coordinates
const radius = 90;
const centerX = 150;
const centerY = 100;

// Start angle for the pie chart parts
let startAngle = 0;

// Start delay coefficient for the pie chart parts
let startDelayCoefficient = 1;

// SVG paths
const paths = [];

// Generate each pie chart part
for (const lang of langs) {
if (langs.length === 1) {
paths.push(`
<circle
cx="${centerX}"
cy="${centerY}"
r="${radius}"
stroke="none"
fill="${lang.color}"
data-testid="lang-pie"
size="100"
/>
`);
break;
}

const langSizePart = lang.size / totalLanguageSize;
const percentage = langSizePart * 100;
// Calculate the angle for the current part
const angle = langSizePart * 360;

// Calculate the end angle
const endAngle = startAngle + angle;

// Calculate the coordinates of the start and end points of the arc
const startPoint = polarToCartesian(centerX, centerY, radius, startAngle);
const endPoint = polarToCartesian(centerX, centerY, radius, endAngle);

// Determine the large arc flag based on the angle
const largeArcFlag = angle > 180 ? 1 : 0;

// Calculate delay
const delay = startDelayCoefficient * 100;

// SVG arc markup
paths.push(`
<g class="stagger" style="animation-delay: ${delay}ms">
<path
data-testid="lang-pie"
size="${percentage}"
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
fill="${lang.color}"
/>
</g>
`);

// Update the start angle for the next part
startAngle = endAngle;
// Update the start delay coefficient for the next part
startDelayCoefficient += 1;
}

return `
<svg data-testid="lang-items">
<g transform="translate(0, 0)">
<svg data-testid="pie">
${paths.join("")}
</svg>
</g>
<g transform="translate(0, 220)">
<svg data-testid="lang-names" x="${CARD_PADDING}">
${createLanguageTextNode({
langs,
totalSize: totalLanguageSize,
hideProgress: false,
})}
</svg>
</g>
</svg>
`;
};

/**
* Creates the SVG paths for the language donut chart.
*
Expand Down Expand Up @@ -504,7 +609,10 @@ const renderTopLanguages = (topLangs, options = {}) => {
let height = calculateNormalLayoutHeight(langs.length);

let finalLayout = "";
if (layout === "compact" || hide_progress == true) {
if (layout === "pie") {
height = calculatePieLayoutHeight(langs.length);
finalLayout = renderPieLayout(langs, totalLanguageSize);
} else if (layout === "compact" || hide_progress == true) {
height =
calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);

Expand Down Expand Up @@ -579,6 +687,10 @@ const renderTopLanguages = (topLangs, options = {}) => {
`,
);

if (layout === "pie") {
return card.render(finalLayout);
}

return card.render(`
<svg data-testid="lang-items" x="${CARD_PADDING}">
${finalLayout}
Expand All @@ -595,6 +707,7 @@ export {
calculateCompactLayoutHeight,
calculateNormalLayoutHeight,
calculateDonutLayoutHeight,
calculatePieLayoutHeight,
donutCenterTranslation,
trimTopLanguages,
renderTopLanguages,
Expand Down
2 changes: 1 addition & 1 deletion src/cards/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export type TopLangOptions = CommonOptions & {
hide_border: boolean;
card_width: number;
hide: string[];
layout: "compact" | "normal" | "donut";
layout: "compact" | "normal" | "donut" | "pie";
custom_title: string;
langs_count: number;
disable_animations: boolean;
Expand Down
127 changes: 123 additions & 4 deletions tests/renderTopLanguages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
calculateCompactLayoutHeight,
calculateNormalLayoutHeight,
calculateDonutLayoutHeight,
calculatePieLayoutHeight,
donutCenterTranslation,
trimTopLanguages,
renderTopLanguages,
Expand Down Expand Up @@ -40,12 +41,13 @@ const langs = {

/**
* Retrieve the language percentage from the donut chart SVG.
*
* @param {string} d The SVG path element.
* @param {number} centerX The center X coordinate of the donut chart.
* @param {number} centerY The center Y coordinate of the donut chart.
* @returns {number} The percentage of the language.
*/
const langPercentFromSvg = (d, centerX, centerY) => {
const langPercentFromDonutLayoutSvg = (d, centerX, centerY) => {
const dTmp = d
.split(" ")
.filter((x) => !isNaN(x))
Expand All @@ -58,6 +60,34 @@ const langPercentFromSvg = (d, centerX, centerY) => {
return (endAngle - startAngle) / 3.6;
};

/**
* Retrieve the language percentage from the pie chart SVG.
*
* @param {string} d The SVG path element.
* @param {number} centerX The center X coordinate of the pie chart.
* @param {number} centerY The center Y coordinate of the pie chart.
* @returns {number} The percentage of the language.
*/
const langPercentFromPieLayoutSvg = (d, centerX, centerY) => {
const dTmp = d
.split(" ")
.filter((x) => !isNaN(x))
.map((x) => parseFloat(x));
const startAngle = cartesianToPolar(
centerX,
centerY,
dTmp[2],
dTmp[3],
).angleInDegrees;
let endAngle = cartesianToPolar(
centerX,
centerY,
dTmp[9],
dTmp[10],
).angleInDegrees;
return ((endAngle - startAngle) / 360) * 100;
};

describe("Test renderTopLanguages helper functions", () => {
it("getLongestLang", () => {
const langArray = Object.values(langs);
Expand Down Expand Up @@ -193,6 +223,20 @@ describe("Test renderTopLanguages helper functions", () => {
expect(calculateDonutLayoutHeight(10)).toBe(375);
});

it("calculatePieLayoutHeight", () => {
expect(calculatePieLayoutHeight(0)).toBe(300);
expect(calculatePieLayoutHeight(1)).toBe(325);
expect(calculatePieLayoutHeight(2)).toBe(325);
expect(calculatePieLayoutHeight(3)).toBe(350);
expect(calculatePieLayoutHeight(4)).toBe(350);
expect(calculatePieLayoutHeight(5)).toBe(375);
expect(calculatePieLayoutHeight(6)).toBe(375);
expect(calculatePieLayoutHeight(7)).toBe(400);
expect(calculatePieLayoutHeight(8)).toBe(400);
expect(calculatePieLayoutHeight(9)).toBe(425);
expect(calculatePieLayoutHeight(10)).toBe(425);
});

it("donutCenterTranslation", () => {
expect(donutCenterTranslation(0)).toBe(-45);
expect(donutCenterTranslation(1)).toBe(-45);
Expand Down Expand Up @@ -466,7 +510,7 @@ describe("Test renderTopLanguages", () => {
.filter((x) => !isNaN(x))
.map((x) => parseFloat(x));
const center = { x: d[7], y: d[7] };
const HTMLLangPercent = langPercentFromSvg(
const HTMLLangPercent = langPercentFromDonutLayoutSvg(
queryAllByTestId(document.body, "lang-donut")[0].getAttribute("d"),
center.x,
center.y,
Expand All @@ -480,7 +524,7 @@ describe("Test renderTopLanguages", () => {
"size",
"40",
);
const javascriptLangPercent = langPercentFromSvg(
const javascriptLangPercent = langPercentFromDonutLayoutSvg(
queryAllByTestId(document.body, "lang-donut")[1].getAttribute("d"),
center.x,
center.y,
Expand All @@ -494,7 +538,7 @@ describe("Test renderTopLanguages", () => {
"size",
"20",
);
const cssLangPercent = langPercentFromSvg(
const cssLangPercent = langPercentFromDonutLayoutSvg(
queryAllByTestId(document.body, "lang-donut")[2].getAttribute("d"),
center.x,
center.y,
Expand All @@ -520,6 +564,81 @@ describe("Test renderTopLanguages", () => {
"circle",
);
});
it("should render with layout pie", () => {
document.body.innerHTML = renderTopLanguages(langs, { layout: "pie" });

expect(queryByTestId(document.body, "header")).toHaveTextContent(
"Most Used Languages",
);

expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
"HTML 40.00%",
);
expect(queryAllByTestId(document.body, "lang-pie")[0]).toHaveAttribute(
"size",
"40",
);

const d = queryAllByTestId(document.body, "lang-pie")[0]
.getAttribute("d")
.split(" ")
.filter((x) => !isNaN(x))
.map((x) => parseFloat(x));
const center = { x: d[0], y: d[1] };
const HTMLLangPercent = langPercentFromPieLayoutSvg(
queryAllByTestId(document.body, "lang-pie")[0].getAttribute("d"),
center.x,
center.y,
);
expect(HTMLLangPercent).toBeCloseTo(40);

expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent(
"javascript 40.00%",
);
expect(queryAllByTestId(document.body, "lang-pie")[1]).toHaveAttribute(
"size",
"40",
);
const javascriptLangPercent = langPercentFromPieLayoutSvg(
queryAllByTestId(document.body, "lang-pie")[1].getAttribute("d"),
center.x,
center.y,
);
expect(javascriptLangPercent).toBeCloseTo(40);

expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent(
"css 20.00%",
);
expect(queryAllByTestId(document.body, "lang-pie")[2]).toHaveAttribute(
"size",
"20",
);
const cssLangPercent = langPercentFromPieLayoutSvg(
queryAllByTestId(document.body, "lang-pie")[2].getAttribute("d"),
center.x,
center.y,
);
expect(cssLangPercent).toBeCloseTo(20);

expect(HTMLLangPercent + javascriptLangPercent + cssLangPercent).toBe(100);

// Should render full pie (circle) if one language is 100%.
document.body.innerHTML = renderTopLanguages(
{ HTML: langs.HTML },
{ layout: "pie" },
);
expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
"HTML 100.00%",
);
expect(queryAllByTestId(document.body, "lang-pie")[0]).toHaveAttribute(
"size",
"100",
);
expect(queryAllByTestId(document.body, "lang-pie")).toHaveLength(1);
expect(queryAllByTestId(document.body, "lang-pie")[0].tagName).toBe(
"circle",
);
});

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