diff --git a/api/top-langs.js b/api/top-langs.js index d63463de..10ecc5d9 100644 --- a/api/top-langs.js +++ b/api/top-langs.js @@ -34,6 +34,7 @@ export default async (req, res) => { border_color, disable_animations, hide_progress, + stats_format, } = req.query; res.setHeader("Content-Type", "image/svg+xml"); @@ -85,6 +86,16 @@ export default async (req, res) => { ); } + if ( + stats_format !== undefined && + (typeof stats_format !== "string" || + !["bytes", "percentages"].includes(stats_format)) + ) { + return res.send( + renderError("Something went wrong", "Incorrect stats_format input"), + ); + } + try { const topLangs = await fetchTopLanguages( username, @@ -125,6 +136,7 @@ export default async (req, res) => { locale: locale ? locale.toLowerCase() : null, disable_animations: parseBoolean(disable_animations), hide_progress: parseBoolean(hide_progress), + stats_format, }), ); } catch (err) { diff --git a/readme.md b/readme.md index cb137590..a6df0d08 100644 --- a/readme.md +++ b/readme.md @@ -74,6 +74,7 @@ - [Donut Vertical Chart Language Card Layout](#donut-vertical-chart-language-card-layout) - [Pie Chart Language Card Layout](#pie-chart-language-card-layout) - [Hide Progress Bars](#hide-progress-bars) + - [Change format of language's stats](#change-format-of-languages-stats) - [Demo](#demo-2) - [WakaTime Stats Card](#wakatime-stats-card) - [Options](#options-3) @@ -468,6 +469,7 @@ You can customize the appearance and behavior of the top languages card using th | `hide_progress` | Uses the compact layout option, hides percentages, and removes the bars. | boolean | `false` | | `size_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `1` | | `count_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `0` | +| `stats_format` | Switches between two available formats for language's stats `percentages` and `bytes`. | enum | `percentages` | > [!WARNING]\ > Language names should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) @@ -556,6 +558,15 @@ 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&hide_progress=true) ``` +### Change format of language's stats + +You can use the `&stats_format=bytes` option to display the stats in bytes instead of percentage. + +```md +![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&stats_format=bytes) +``` + + ### Demo ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra) @@ -580,6 +591,11 @@ 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\&hide_progress=true) + +* Display bytes instead of percentage + +![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra\&stats_format=bytes) + # WakaTime Stats Card > [!WARNING]\ diff --git a/src/cards/top-languages.js b/src/cards/top-languages.js index 9385f4a7..fec72c7c 100644 --- a/src/cards/top-languages.js +++ b/src/cards/top-languages.js @@ -9,6 +9,7 @@ import { getCardColors, lowercaseTrim, measureText, + formatBytes, } from "../common/utils.js"; import { langCardLocales } from "../translations.js"; @@ -196,6 +197,18 @@ const trimTopLanguages = (topLangs, langs_count, hide) => { return { langs, totalLanguageSize }; }; +/** + * Get display value corresponding to the format. + * + * @param {number} size Bytes size. + * @param {number} percentages Percentage value. + * @param {string} format Format of the stats. + * @returns {string} Display value. + */ +const getDisplayValue = (size, percentages, format) => { + return format === "bytes" ? formatBytes(size) : `${percentages.toFixed(2)}%`; +}; + /** * Create progress bar text item for a programming language. * @@ -203,20 +216,33 @@ const trimTopLanguages = (topLangs, langs_count, hide) => { * @param {number} props.width The card width * @param {string} props.color Color of the programming language. * @param {string} props.name Name of the programming language. - * @param {number} props.progress Usage of the programming language in percentage. + * @param {number} props.size Size of the programming language. + * @param {number} props.totalSize Total size of all languages. + * @param {string} props.statsFormat Stats format. * @param {number} props.index Index of the programming language. * @returns {string} Programming language SVG node. */ -const createProgressTextNode = ({ width, color, name, progress, index }) => { +const createProgressTextNode = ({ + width, + color, + name, + size, + totalSize, + statsFormat, + index, +}) => { const staggerDelay = (index + 3) * 150; const paddingRight = 95; const progressTextX = width - paddingRight + 10; const progressWidth = width - paddingRight; + const progress = (size / totalSize) * 100; + const displayValue = getDisplayValue(size, progress, statsFormat); + return ` ${name} - ${progress}% + ${displayValue} ${createProgressNode({ x: 0, y: 25, @@ -237,11 +263,20 @@ const createProgressTextNode = ({ width, color, name, progress, index }) => { * @param {Lang} props.lang Programming language object. * @param {number} props.totalSize Total size of all languages. * @param {boolean=} props.hideProgress Whether to hide percentage. + * @param {string=} props.statsFormat Stats format * @param {number} props.index Index of the programming language. * @returns {string} Compact layout programming language SVG node. */ -const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => { - const percentage = ((lang.size / totalSize) * 100).toFixed(2); +const createCompactLangNode = ({ + lang, + totalSize, + hideProgress, + statsFormat = "percentages", + index, +}) => { + const percentages = (lang.size / totalSize) * 100; + const displayValue = getDisplayValue(lang.size, percentages, statsFormat); + const staggerDelay = (index + 3) * 150; const color = lang.color || "#858585"; @@ -249,7 +284,7 @@ const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => { - ${lang.name} ${hideProgress ? "" : percentage + "%"} + ${lang.name} ${hideProgress ? "" : displayValue} `; @@ -262,9 +297,15 @@ const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => { * @param {Lang[]} props.langs Array of programming languages. * @param {number} props.totalSize Total size of all languages. * @param {boolean=} props.hideProgress Whether to hide percentage. + * @param {string=} props.statsFormat Stats format * @returns {string} Programming languages SVG node. */ -const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => { +const createLanguageTextNode = ({ + langs, + totalSize, + hideProgress, + statsFormat, +}) => { const longestLang = getLongestLang(langs); const chunked = chunkArray(langs, langs.length / 2); const layouts = chunked.map((array) => { @@ -274,6 +315,7 @@ const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => { lang, totalSize, hideProgress, + statsFormat, index, }), ); @@ -299,15 +341,17 @@ const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => { * @param {object} props Function properties. * @param {Lang[]} props.langs Array of programming languages. * @param {number} props.totalSize Total size of all languages. + * @param {string} props.statsFormat Stats format * @returns {string} Donut layout programming language SVG node. */ -const createDonutLanguagesNode = ({ langs, totalSize }) => { +const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => { return flexLayout({ items: langs.map((lang, index) => { return createCompactLangNode({ lang, totalSize, hideProgress: false, + statsFormat, index, }); }), @@ -322,18 +366,19 @@ const createDonutLanguagesNode = ({ langs, totalSize }) => { * @param {Lang[]} langs Array of programming languages. * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. + * @param {string} statsFormat Stats format. * @returns {string} Normal layout card SVG object. */ -const renderNormalLayout = (langs, width, totalLanguageSize) => { +const renderNormalLayout = (langs, width, totalLanguageSize, statsFormat) => { return flexLayout({ items: langs.map((lang, index) => { return createProgressTextNode({ width, name: lang.name, color: lang.color || DEFAULT_LANG_COLOR, - progress: parseFloat( - ((lang.size / totalLanguageSize) * 100).toFixed(2), - ), + size: lang.size, + totalSize: totalLanguageSize, + statsFormat, index, }); }), @@ -349,9 +394,16 @@ const renderNormalLayout = (langs, width, totalLanguageSize) => { * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. * @param {boolean=} hideProgress Whether to hide progress bar. + * @param {string} statsFormat Stats format. * @returns {string} Compact layout card SVG object. */ -const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => { +const renderCompactLayout = ( + langs, + width, + totalLanguageSize, + hideProgress, + statsFormat = "percentages", +) => { const paddingRight = 50; const offsetWidth = width - paddingRight; // progressOffset holds the previous language's width and used to offset the next language @@ -397,6 +449,7 @@ const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => { langs, totalSize: totalLanguageSize, hideProgress, + statsFormat, })} `; @@ -407,9 +460,10 @@ const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => { * * @param {Lang[]} langs Array of programming languages. * @param {number} totalLanguageSize Total size of all languages. + * @param {string} statsFormat Stats format. * @returns {string} Compact layout card SVG object. */ -const renderDonutVerticalLayout = (langs, totalLanguageSize) => { +const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => { // Donut vertical chart radius and total length const radius = 80; const totalCircleLength = getCircleLength(radius); @@ -465,6 +519,7 @@ const renderDonutVerticalLayout = (langs, totalLanguageSize) => { langs, totalSize: totalLanguageSize, hideProgress: false, + statsFormat, })} @@ -477,9 +532,10 @@ const renderDonutVerticalLayout = (langs, totalLanguageSize) => { * * @param {Lang[]} langs Array of programming languages. * @param {number} totalLanguageSize Total size of all languages. + * @param {string} statsFormat Stats format. * @returns {string} Compact layout card SVG object. */ -const renderPieLayout = (langs, totalLanguageSize) => { +const renderPieLayout = (langs, totalLanguageSize, statsFormat) => { // Pie chart radius and center coordinates const radius = 90; const centerX = 150; @@ -560,6 +616,7 @@ const renderPieLayout = (langs, totalLanguageSize) => { langs, totalSize: totalLanguageSize, hideProgress: false, + statsFormat, })} @@ -610,9 +667,10 @@ const createDonutPaths = (cx, cy, radius, percentages) => { * @param {Lang[]} langs Array of programming languages. * @param {number} width Card width. * @param {number} totalLanguageSize Total size of all languages. + * @param {string} statsFormat Stats format. * @returns {string} Donut layout card SVG object. */ -const renderDonutLayout = (langs, width, totalLanguageSize) => { +const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => { const centerX = width / 3; const centerY = width / 3; const radius = centerX - 60; @@ -655,7 +713,7 @@ const renderDonutLayout = (langs, width, totalLanguageSize) => { return ` - ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize })} + ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })} @@ -738,6 +796,7 @@ const renderTopLanguages = (topLangs, options = {}) => { border_radius, border_color, disable_animations, + stats_format = "percentages", } = options; const i18n = new I18n({ @@ -779,10 +838,14 @@ const renderTopLanguages = (topLangs, options = {}) => { }); } else if (layout === "pie") { height = calculatePieLayoutHeight(langs.length); - finalLayout = renderPieLayout(langs, totalLanguageSize); + finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format); } else if (layout === "donut-vertical") { height = calculateDonutVerticalLayoutHeight(langs.length); - finalLayout = renderDonutVerticalLayout(langs, totalLanguageSize); + finalLayout = renderDonutVerticalLayout( + langs, + totalLanguageSize, + stats_format, + ); } else if (layout === "compact" || hide_progress == true) { height = calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0); @@ -792,13 +855,24 @@ const renderTopLanguages = (topLangs, options = {}) => { width, totalLanguageSize, hide_progress, + stats_format, ); } else if (layout === "donut") { height = calculateDonutLayoutHeight(langs.length); width = width + 50; // padding - finalLayout = renderDonutLayout(langs, width, totalLanguageSize); + finalLayout = renderDonutLayout( + langs, + width, + totalLanguageSize, + stats_format, + ); } else { - finalLayout = renderNormalLayout(langs, width, totalLanguageSize); + finalLayout = renderNormalLayout( + langs, + width, + totalLanguageSize, + stats_format, + ); } const card = new Card({ diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts index 9a21be4a..08f80ef2 100644 --- a/src/cards/types.d.ts +++ b/src/cards/types.d.ts @@ -44,6 +44,7 @@ export type TopLangOptions = CommonOptions & { langs_count: number; disable_animations: boolean; hide_progress: boolean; + stats_format: "percentages" | "bytes"; }; export type WakaTimeOptions = CommonOptions & { diff --git a/src/common/utils.js b/src/common/utils.js index b12a9147..04b02e2c 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -598,6 +598,33 @@ const dateDiff = (d1, d2) => { return Math.round(diff / (1000 * 60)); }; +/** + * Convert bytes to a human-readable string representation. + * + * @param {number} bytes The number of bytes to convert. + * @returns {string} The human-readable representation of bytes. + * @throws {Error} If bytes is negative or too large. + */ +const formatBytes = (bytes) => { + if (bytes < 0) { + throw new Error("Bytes must be a non-negative number"); + } + + if (bytes === 0) { + return "0 B"; + } + + const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + const base = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(base)); + + if (i >= sizes.length) { + throw new Error("Bytes is too large to convert to a human-readable string"); + } + + return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`; +}; + export { ERROR_CARD_LENGTH, renderError, @@ -624,4 +651,5 @@ export { chunkArray, parseEmojis, dateDiff, + formatBytes, }; diff --git a/tests/renderTopLanguagesCard.test.js b/tests/renderTopLanguagesCard.test.js index 55965da0..0a7aba61 100644 --- a/tests/renderTopLanguagesCard.test.js +++ b/tests/renderTopLanguagesCard.test.js @@ -849,4 +849,40 @@ describe("Test renderTopLanguages", () => { "No languages data.", ); }); + + it("should show proper stats format", () => { + document.body.innerHTML = renderTopLanguages(langs, { + layout: "compact", + stats_format: "percentages", + }); + + expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( + "HTML 40.00%", + ); + + expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( + "javascript 40.00%", + ); + + expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( + "css 20.00%", + ); + + document.body.innerHTML = renderTopLanguages(langs, { + layout: "compact", + stats_format: "bytes", + }); + + expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent( + "HTML 200.0 B", + ); + + expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent( + "javascript 200.0 B", + ); + + expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent( + "css 100.0 B", + ); + }); }); diff --git a/tests/utils.test.js b/tests/utils.test.js index 5bd43955..7277b95d 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -7,6 +7,7 @@ import { parseBoolean, renderError, wrapTextMultiline, + formatBytes, } from "../src/common/utils.js"; import { expect, it, describe } from "@jest/globals"; @@ -134,6 +135,20 @@ describe("Test utils.js", () => { borderColor: "#fff", }); }); + + it("formatBytes: should return expected values", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(100)).toBe("100.0 B"); + expect(formatBytes(1024)).toBe("1.0 KB"); + expect(formatBytes(1024 * 1024)).toBe("1.0 MB"); + expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB"); + expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB"); + expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 PB"); + expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 EB"); + + expect(formatBytes(1234 * 1024)).toBe("1.2 MB"); + expect(formatBytes(123.4 * 1024)).toBe("123.4 KB"); + }); }); describe("wrapTextMultiline", () => {