diff --git a/readme.md b/readme.md index 5f3c1254..d1e56775 100644 --- a/readme.md +++ b/readme.md @@ -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`). @@ -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) diff --git a/src/cards/top-languages-card.js b/src/cards/top-languages-card.js index ed51f1c9..e03e8bcb 100644 --- a/src/cards/top-languages-card.js +++ b/src/cards/top-languages-card.js @@ -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. @@ -361,6 +371,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(` + + `); + 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(` + + + + `); + + // Update the start angle for the next part + startAngle = endAngle; + // Update the start delay coefficient for the next part + startDelayCoefficient += 1; + } + + return ` + + + + ${paths.join("")} + + + + + ${createLanguageTextNode({ + langs, + totalSize: totalLanguageSize, + hideProgress: false, + })} + + + + `; +}; + /** * Creates the SVG paths for the language donut chart. * @@ -505,7 +610,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); @@ -580,6 +688,10 @@ const renderTopLanguages = (topLangs, options = {}) => { `, ); + if (layout === "pie") { + return card.render(finalLayout); + } + return card.render(` ${finalLayout} @@ -596,6 +708,7 @@ export { calculateCompactLayoutHeight, calculateNormalLayoutHeight, calculateDonutLayoutHeight, + calculatePieLayoutHeight, donutCenterTranslation, trimTopLanguages, renderTopLanguages, diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index fea5aa95..7945118c 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -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; diff --git a/tests/renderTopLanguages.test.js b/tests/renderTopLanguages.test.js index e4f47c39..e4bc56de 100644 --- a/tests/renderTopLanguages.test.js +++ b/tests/renderTopLanguages.test.js @@ -9,6 +9,7 @@ import { calculateCompactLayoutHeight, calculateNormalLayoutHeight, calculateDonutLayoutHeight, + calculatePieLayoutHeight, donutCenterTranslation, trimTopLanguages, renderTopLanguages, @@ -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)) @@ -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); @@ -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); @@ -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, @@ -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, @@ -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, @@ -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" });