Files
github-readme-stats/src/cards/stats-card.js
T
1bb65ddc29 Added pr reviews to stats (#1404)
* Added pr reviews to stats

* Add 'show' query parameter

* Fix show test

* refactoring

* cleanup

* refactor: restructure stats-card code

---------

Co-authored-by: Markus <markus.tyrkko@gmail.com>
Co-authored-by: Alexandr <qwerty541zxc@gmail.com>
Co-authored-by: rickstaa <rick.staa@outlook.com>
2023-06-15 09:35:09 +02:00

347 lines
9.0 KiB
JavaScript

// @ts-check
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
import { icons, rankIcon } from "../common/icons.js";
import {
clampValue,
flexLayout,
getCardColors,
kFormatter,
measureText,
} from "../common/utils.js";
import { getStyles } from "../getStyles.js";
import { statCardLocales } from "../translations.js";
const CARD_MIN_WIDTH = 287;
const CARD_DEFAULT_WIDTH = 287;
const RANK_CARD_MIN_WIDTH = 420;
const RANK_CARD_DEFAULT_WIDTH = 450;
/**
* Create a stats card text item.
*
* @param {object} createTextNodeParams Object that contains the createTextNode parameters.
* @param {string} createTextNodeParams.icon The icon to display.
* @param {string} createTextNodeParams.label The label to display.
* @param {number} createTextNodeParams.value The value to display.
* @param {string} createTextNodeParams.id The id of the stat.
* @param {number} createTextNodeParams.index The index of the stat.
* @param {boolean} createTextNodeParams.showIcons Whether to show icons.
* @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right.
* @param {boolean} createTextNodeParams.bold Whether to bold the label.
* @param {string} createTextNodeParams.number_format The format of numbers on card.
* @returns
*/
const createTextNode = ({
icon,
label,
value,
id,
index,
showIcons,
shiftValuePos,
bold,
number_format,
}) => {
const kValue =
number_format.toLowerCase() === "long" ? value : kFormatter(value);
const staggerDelay = (index + 3) * 150;
const labelOffset = showIcons ? `x="25"` : "";
const iconSvg = showIcons
? `
<svg data-testid="icon" class="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
${icon}
</svg>
`
: "";
return `
<g class="stagger" style="animation-delay: ${staggerDelay}ms" transform="translate(25, 0)">
${iconSvg}
<text class="stat ${
bold ? " bold" : "not_bold"
}" ${labelOffset} y="12.5">${label}:</text>
<text
class="stat ${bold ? " bold" : "not_bold"}"
x="${(showIcons ? 140 : 120) + shiftValuePos}"
y="12.5"
data-testid="${id}"
>${kValue}</text>
</g>
`;
};
/**
* Renders the stats card.
*
* @param {Partial<import('../fetchers/types').StatsData>} stats The stats data.
* @param {Partial<import("./types").StatCardOptions>} options The card options.
* @returns {string} The stats card SVG object.
*/
const renderStatsCard = (stats = {}, options = { hide: [] }) => {
const {
name,
totalStars,
totalCommits,
totalIssues,
totalPRs,
totalReviews,
contributedTo,
rank,
} = stats;
const {
hide = [],
show_icons = false,
hide_title = false,
hide_border = false,
card_width,
hide_rank = false,
include_all_commits = false,
line_height = 25,
title_color,
ring_color,
icon_color,
text_color,
text_bold = true,
bg_color,
theme = "default",
custom_title,
border_radius,
border_color,
number_format = "short",
locale,
disable_animations = false,
rank_icon = "default",
show_total_reviews = false,
} = options;
const lheight = parseInt(String(line_height), 10);
// returns theme based colors with proper overrides and defaults
const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } =
getCardColors({
title_color,
text_color,
icon_color,
bg_color,
border_color,
ring_color,
theme,
});
const apostrophe = ["x", "s"].includes(name.slice(-1).toLocaleLowerCase())
? ""
: "s";
const i18n = new I18n({
locale,
translations: statCardLocales({ name, apostrophe }),
});
// Meta data for creating text nodes with createTextNode function
const STATS = {};
STATS.stars = {
icon: icons.star,
label: i18n.t("statcard.totalstars"),
value: totalStars,
id: "stars",
};
STATS.commits = {
icon: icons.commits,
label: `${i18n.t("statcard.commits")}${
include_all_commits ? "" : ` (${new Date().getFullYear()})`
}`,
value: totalCommits,
id: "commits",
};
STATS.prs = {
icon: icons.prs,
label: i18n.t("statcard.prs"),
value: totalPRs,
id: "prs",
};
STATS.issues = {
icon: icons.issues,
label: i18n.t("statcard.issues"),
value: totalIssues,
id: "issues",
};
STATS.contribs = {
icon: icons.contribs,
label: i18n.t("statcard.contribs"),
value: contributedTo,
id: "contribs",
};
// Extra stats items.
if (show_total_reviews) {
STATS.reviews = {
icon: icons.reviews,
label: i18n.t("statcard.reviews"),
value: totalReviews,
id: "reviews",
};
}
const longLocales = [
"cn",
"es",
"fr",
"pt-br",
"ru",
"uk-ua",
"id",
"my",
"pl",
"de",
"nl",
"zh-tw",
];
const isLongLocale = longLocales.includes(locale);
// filter out hidden stats defined by user & create the text nodes
const statItems = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key, index) =>
// create the text nodes, and pass index so that we can calculate the line spacing
createTextNode({
...STATS[key],
index,
showIcons: show_icons,
shiftValuePos: 79.01 + (isLongLocale ? 50 : 0),
bold: text_bold,
number_format,
}),
);
// Calculate the card height depending on how many items there are
// but if rank circle is visible clamp the minimum height to `150`
let height = Math.max(
45 + (statItems.length + 1) * lheight,
hide_rank ? 0 : 150,
);
// the lower the user's percentile the better
const progress = 100 - rank.percentile;
const cssStyles = getStyles({
titleColor,
ringColor,
textColor,
iconColor,
show_icons,
progress,
});
const calculateTextWidth = () => {
return measureText(custom_title ? custom_title : i18n.t("statcard.title"));
};
/*
When hide_rank=true, the minimum card width is 270 px + the title length and padding.
When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true).
Numbers are picked by looking at existing dimensions on production.
*/
const iconWidth = show_icons ? 16 + /* padding */ 1 : 0;
const minCardWidth =
(hide_rank
? clampValue(
50 /* padding */ + calculateTextWidth() * 2,
CARD_MIN_WIDTH,
Infinity,
)
: RANK_CARD_MIN_WIDTH) + iconWidth;
const defaultCardWidth =
(hide_rank ? CARD_DEFAULT_WIDTH : RANK_CARD_DEFAULT_WIDTH) + iconWidth;
let width = isNaN(card_width) ? defaultCardWidth : card_width;
if (width < minCardWidth) {
width = minCardWidth;
}
const card = new Card({
customTitle: custom_title,
defaultTitle: i18n.t("statcard.title"),
width,
height,
border_radius,
colors: {
titleColor,
textColor,
iconColor,
bgColor,
borderColor,
},
});
card.setHideBorder(hide_border);
card.setHideTitle(hide_title);
card.setCSS(cssStyles);
if (disable_animations) card.disableAnimations();
/**
* Calculates the right rank circle translation values such that the rank circle
* keeps respecting the following padding:
*
* width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used.
* width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged
* equally from a certain minimum at RANK_CARD_MIN_WIDTH.
*
* @returns {number} - Rank circle translation value.
*/
const calculateRankXTranslation = () => {
const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70;
if (width > RANK_CARD_DEFAULT_WIDTH) {
const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2;
return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH;
} else {
return minXTranslation + (width - minCardWidth) / 2;
}
};
// Conditionally rendered elements
const rankCircle = hide_rank
? ""
: `<g data-testid="rank-circle"
transform="translate(${calculateRankXTranslation()}, ${
height / 2 - 50
})">
<circle class="rank-circle-rim" cx="-10" cy="8" r="40" />
<circle class="rank-circle" cx="-10" cy="8" r="40" />
<g class="rank-text">
${rankIcon(rank_icon, rank?.level)}
</g>
</g>`;
// Accessibility Labels
const labels = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key) => {
if (key === "commits") {
return `${i18n.t("statcard.commits")} ${
include_all_commits ? "" : `in ${new Date().getFullYear()}`
} : ${totalStars}`;
}
return `${STATS[key].label}: ${STATS[key].value}`;
})
.join(", ");
card.setAccessibilityLabel({
title: `${card.title}, Rank: ${rank.level}`,
desc: labels,
});
return card.render(`
${rankCircle}
<svg x="0" y="0">
${flexLayout({
items: statItems,
gap: lheight,
direction: "column",
}).join("")}
</svg>
`);
};
export { renderStatsCard };
export default renderStatsCard;