diff --git a/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx b/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx
index 452699499..80d7011d8 100644
--- a/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx
+++ b/apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx
@@ -6,16 +6,20 @@
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/
-import { BedWarsModes, FormattedGame, type GameMode } from "@statsify/schemas";
import {
+ Badge,
+ BarChart,
Container,
Footer,
+ Graph,
Header,
Historical,
SidebarItem,
+ StatDelta,
Table,
formatProgression,
} from "#components";
+import { BedWarsModes, FormattedGame, type GameMode } from "@statsify/schemas";
import type { BaseProfileProps } from "#commands/base.hypixel-command";
export interface BedWarsProfileProps extends BaseProfileProps {
@@ -35,6 +39,22 @@ export const BedWarsProfile = ({
}: BedWarsProfileProps) => {
const { bedwars } = player.stats;
const stats = bedwars[mode.api];
+ const modeWins = [
+ { label: "Solo", value: bedwars.solo.wins || 0, color: "#4e79a7" },
+ { label: "Doubles", value: bedwars.doubles.wins || 0, color: "#76b7b2" },
+ { label: "3s", value: bedwars.threes.wins || 0, color: "#f28e2b" },
+ { label: "4s", value: bedwars.fours.wins || 0, color: "#e15759" },
+ { label: "4v4", value: bedwars["4v4"].wins || 0, color: "#9ca3af" },
+ ];
+ const ratioPoints = [
+ { label: t("stats.wlr"), value: stats.wlr || 0 },
+ { label: t("stats.fkdr"), value: stats.fkdr || 0 },
+ { label: t("stats.kdr"), value: stats.kdr || 0 },
+ { label: t("stats.bblr"), value: stats.bblr || 0 },
+ ];
+ const ratioAverage =
+ ratioPoints.reduce((total, point) => total + point.value, 0) / ratioPoints.length;
+ const fkdrDelta = (stats.fkdr || 0) - (bedwars.overall.fkdr || 0);
const sidebar: SidebarItem[] = [
[t("stats.tokens"), t(bedwars.tokens), "§2"],
@@ -113,6 +133,32 @@ export const BedWarsProfile = ({
exp={bedwars.exp}
/>
+
+
+
+
§lMode Wins
+
+
§bVisual
+
+
+
+
+
+ point.value))}
+ referenceValue={ratioAverage}
+ color="#9ca3af"
+ fillColor="rgba(156, 163, 175, 0.12)"
+ />
+
+
);
diff --git a/apps/discord-bot/src/commands/demo/demo.command.tsx b/apps/discord-bot/src/commands/demo/demo.command.tsx
new file mode 100644
index 000000000..63bb951e8
--- /dev/null
+++ b/apps/discord-bot/src/commands/demo/demo.command.tsx
@@ -0,0 +1,78 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import {
+ ApiService,
+ Command,
+ CommandContext,
+ PaginateService,
+ PlayerArgument,
+} from "@statsify/discord";
+import { Container } from "typedi";
+import { getBackground, getLogo } from "@statsify/assets";
+import { renderChartsPage } from "./pages/charts-graphs.js";
+import { renderComparisonPage } from "./pages/comparison-ranking.js";
+import { renderRadialPage } from "./pages/radial-distribution.js";
+import { renderStatPrimitivesPage } from "./pages/stat-primitives.js";
+import { renderThemingPage } from "./pages/theming-kitchen.js";
+import type { Image } from "skia-canvas";
+import type { Player, User } from "@statsify/schemas";
+
+export interface DemoPageProps {
+ player: Player;
+ skin: Image;
+ logo: Image;
+ badge?: Image;
+ background: Image;
+ user: User | null;
+}
+
+const DEFAULT_PLAYER = "hypixel";
+
+@Command({
+ description: () => "Component showcase (rendering demo)",
+ args: [new PlayerArgument("player", false)],
+ cooldown: 10,
+})
+export class DemoCommand {
+ private readonly apiService: ApiService;
+ private readonly paginateService: PaginateService;
+
+ public constructor() {
+ this.apiService = Container.get(ApiService);
+ this.paginateService = Container.get(PaginateService);
+ }
+
+ public async run(context: CommandContext) {
+ const user = context.getUser();
+ const playerTag = context.option("player") ?? DEFAULT_PLAYER;
+
+ const [player, background, logo] = await Promise.all([
+ this.apiService.getPlayer(playerTag, user),
+ getBackground("bedwars", "overall"),
+ getLogo(user),
+ ]);
+
+ const [skin, badge] = await Promise.all([
+ this.apiService.getPlayerSkin(player.uuid, user),
+ this.apiService.getUserBadge(player.uuid),
+ ]);
+
+ const props: DemoPageProps = { player, skin, logo, badge, background, user };
+
+ const pages = [
+ { label: () => "Charts & Graphs", generator: () => renderChartsPage(props) },
+ { label: () => "Radial & Distribution", generator: () => renderRadialPage(props) },
+ { label: () => "Stat Primitives", generator: () => renderStatPrimitivesPage(props) },
+ { label: () => "Comparison & Ranking", generator: () => renderComparisonPage(props) },
+ { label: () => "Theming & Kitchen Sink", generator: () => renderThemingPage(props) },
+ ];
+
+ return this.paginateService.paginate(context, pages);
+ }
+}
diff --git a/apps/discord-bot/src/commands/demo/pages/charts-graphs.tsx b/apps/discord-bot/src/commands/demo/pages/charts-graphs.tsx
new file mode 100644
index 000000000..aa2c916ca
--- /dev/null
+++ b/apps/discord-bot/src/commands/demo/pages/charts-graphs.tsx
@@ -0,0 +1,104 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { BarChart, Container, Footer, GAME_COLORS, Graph, Header } from "#components";
+import { getTheme } from "#themes";
+import { render } from "@statsify/rendering";
+import type { DemoPageProps } from "../demo.command.js";
+
+const seededPoints = (uuid: string, base: number, count: number) => {
+ const hex = uuid.replace(/-/g, "");
+ return Array.from({ length: count }, (_, i) => {
+ const h = hex.slice((i * 2) % 32, (i * 2) % 32 + 2);
+ const jitter = (Number.parseInt(h, 16) / 255 - 0.5) * 0.4;
+ return { label: `W${i + 1}`, value: Math.max(0, base * (1 + jitter)) };
+ });
+};
+
+export const renderChartsPage = ({
+ player,
+ skin,
+ logo,
+ badge,
+ background,
+ user,
+}: DemoPageProps) => {
+ const bw = player.stats.bedwars;
+ const overall = bw.overall;
+
+ const fkdrPoints = seededPoints(player.uuid, overall.fkdr, 10);
+ const wlrPoints = seededPoints(player.uuid, overall.wlr, 10);
+
+ const modeWins = [
+ { label: "Solo", value: bw.solo.wins || 0, color: GAME_COLORS.bedwars },
+ { label: "Doubles", value: bw.doubles.wins || 0, color: "#f97316" },
+ { label: "3s", value: bw.threes.wins || 0, color: "#facc15" },
+ { label: "4s", value: bw.fours.wins || 0, color: "#a78bfa" },
+ { label: "4v4", value: bw["4v4"].wins || 0, color: "#38bdf8" },
+ ];
+
+ const activityData = Array.from({ length: 12 }, (_, i) => {
+ const h = player.uuid.replace(/-/g, "").slice(i * 2, i * 2 + 2);
+ return Math.round((Number.parseInt(h, 16) / 255) * 80);
+ });
+
+ const canvas = render(
+
+
+
+
+
+ §lFKDR §8vs §7WLR Trend
+
+
+
+ §lRecent Activity
+
+
+
+
+
+ §lMode Wins (Vertical)
+
+
+
+ §lMode Wins (Horizontal)
+
+
+
+
+
+ ,
+ getTheme(user)
+ );
+
+ return canvas;
+};
diff --git a/apps/discord-bot/src/commands/demo/pages/comparison-ranking.tsx b/apps/discord-bot/src/commands/demo/pages/comparison-ranking.tsx
new file mode 100644
index 000000000..29ffec860
--- /dev/null
+++ b/apps/discord-bot/src/commands/demo/pages/comparison-ranking.tsx
@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import {
+ Container,
+ Footer,
+ GAME_COLORS,
+ Header,
+ HeatmapChart,
+ LeaderboardRow,
+ VersusPanel,
+} from "#components";
+import { getTheme } from "#themes";
+import { render } from "@statsify/rendering";
+import type { DemoPageProps } from "../demo.command.js";
+
+const HYPIXEL_BW_AVERAGES = {
+ fkdr: 1.2,
+ wlr: 0.8,
+ kdr: 0.9,
+ bblr: 0.7,
+};
+
+const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+
+export const renderComparisonPage = ({
+ player,
+ skin,
+ logo,
+ badge,
+ background,
+ user,
+}: DemoPageProps) => {
+ const bw = player.stats.bedwars;
+ const overall = bw.overall;
+
+ const heatCells = Array.from({ length: 7 * 8 }, (_, i) => {
+ const hex = player.uuid.replace(/-/g, "");
+ const h = hex.slice(i % 32, i % 32 + 2);
+ return Math.round((Number.parseInt(h, 16) / 255) * 60);
+ });
+
+ const leaderboardEntries = [
+ { rank: 1, name: "§6§lHyperion_Max", value: 28.4 },
+ { rank: 2, name: "§b§lSkyblaze", value: 21.7 },
+ { rank: 3, name: "§a§lVineBreaker", value: 18.3 },
+ { rank: 4, name: player.username, value: overall.fkdr },
+ { rank: 5, name: "§7VoidStrider", value: Math.max(0, overall.fkdr - 0.8) },
+ ].sort((a, b) => b.value - a.value).map((e, i) => ({ ...e, rank: i + 1 }));
+
+ const leftPlayer = {
+ name: player.prefixName,
+ stats: [
+ { label: "FKDR", leftValue: overall.fkdr, rightValue: HYPIXEL_BW_AVERAGES.fkdr },
+ { label: "WLR", leftValue: overall.wlr, rightValue: HYPIXEL_BW_AVERAGES.wlr },
+ { label: "KDR", leftValue: overall.kdr, rightValue: HYPIXEL_BW_AVERAGES.kdr },
+ { label: "BBLR", leftValue: overall.bblr, rightValue: HYPIXEL_BW_AVERAGES.bblr },
+ ],
+ };
+
+ const rightPlayer = {
+ name: "§7Hypixel Average",
+ stats: [
+ { label: "FKDR", leftValue: overall.fkdr, rightValue: HYPIXEL_BW_AVERAGES.fkdr },
+ { label: "WLR", leftValue: overall.wlr, rightValue: HYPIXEL_BW_AVERAGES.wlr },
+ { label: "KDR", leftValue: overall.kdr, rightValue: HYPIXEL_BW_AVERAGES.kdr },
+ { label: "BBLR", leftValue: overall.bblr, rightValue: HYPIXEL_BW_AVERAGES.bblr },
+ ],
+ };
+
+ const canvas = render(
+
+
+
+
+
+ §lvs Hypixel Average
+
+
+
+ {[
+ §lFKDR Leaderboard (Simulated),
+ ...leaderboardEntries.map((e) => (
+
+ )),
+ ]}
+
+
+
+
+ §lWeekly Activity Heatmap §8(seeded)
+
+
+
+
+
+ ,
+ getTheme(user)
+ );
+
+ return canvas;
+};
diff --git a/apps/discord-bot/src/commands/demo/pages/radial-distribution.tsx b/apps/discord-bot/src/commands/demo/pages/radial-distribution.tsx
new file mode 100644
index 000000000..b752a0607
--- /dev/null
+++ b/apps/discord-bot/src/commands/demo/pages/radial-distribution.tsx
@@ -0,0 +1,125 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import {
+ Container,
+ Footer,
+ GAME_COLORS,
+ Gauge,
+ Header,
+ ProgressBar,
+ RadarChart,
+} from "#components";
+import { LocalizeFunction } from "@statsify/discord";
+import { getTheme } from "#themes";
+import { render } from "@statsify/rendering";
+import type { DemoPageProps } from "../demo.command.js";
+
+const fakeT = String as unknown as LocalizeFunction;
+
+export const renderRadialPage = ({
+ player,
+ skin,
+ logo,
+ badge,
+ background,
+ user,
+}: DemoPageProps) => {
+ const bw = player.stats.bedwars;
+ const overall = bw.overall;
+
+ const levelFrac = bw.level - Math.floor(bw.level);
+ const levelPct = Math.round(levelFrac * 100);
+ const totalWins = bw.overall.wins || 0;
+ const totalLosses = bw.overall.losses || 1;
+
+ const radarAxes = [
+ { label: "FKDR", value: overall.fkdr, max: 10 },
+ { label: "WLR", value: overall.wlr, max: 5 },
+ { label: "KDR", value: overall.kdr, max: 5 },
+ { label: "BBLR", value: overall.bblr, max: 5 },
+ { label: "FK/G", value: overall.fkdr * 2.5, max: 20 },
+ ];
+
+ const modeWins = [
+ { value: bw.solo.wins || 1, color: GAME_COLORS.bedwars },
+ { value: bw.doubles.wins || 1, color: "#f97316" },
+ { value: bw.threes.wins || 1, color: "#facc15" },
+ { value: bw.fours.wins || 1, color: "#a78bfa" },
+ { value: bw["4v4"].wins || 1, color: "#38bdf8" },
+ ];
+
+ const canvas = render(
+
+
+
+
+
+ §lPrestige Level
+
+
+
+ §lWin Rate
+
+
+
+
+
+ §lStat Radar
+
+
+
+
+
+ §lMode Distribution
+
+
+
+ §lMode Share (Pie)
+
+
+
+
+
+ ,
+ getTheme(user)
+ );
+
+ return canvas;
+};
diff --git a/apps/discord-bot/src/commands/demo/pages/stat-primitives.tsx b/apps/discord-bot/src/commands/demo/pages/stat-primitives.tsx
new file mode 100644
index 000000000..cea6836c4
--- /dev/null
+++ b/apps/discord-bot/src/commands/demo/pages/stat-primitives.tsx
@@ -0,0 +1,138 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import {
+ Badge,
+ Container,
+ Footer,
+ GAME_COLORS,
+ Header,
+ PrestigeIcon,
+ RankTag,
+ StatDelta,
+ StatGrid,
+ Trend,
+} from "#components";
+import { getTheme } from "#themes";
+import { render } from "@statsify/rendering";
+import type { DemoPageProps } from "../demo.command.js";
+
+const seededSpark = (uuid: string, base: number) => {
+ const hex = uuid.replace(/-/g, "");
+ return Array.from({ length: 8 }, (_, i) => {
+ const h = hex.slice((i * 3) % 30, (i * 3) % 30 + 2);
+ return Math.max(0, base + (Number.parseInt(h, 16) / 255 - 0.5) * base * 0.6);
+ });
+};
+
+const BEDWARS_PRESTIGE: Array<{ min: number; color: string; label: string }> = [
+ { min: 0, color: "#9ca3af", label: "None" },
+ { min: 100, color: "#ef4444", label: "Ruby" },
+ { min: 500, color: "#a855f7", label: "Amethyst" },
+ { min: 1000, color: "#eab308", label: "Gold" },
+ { min: 2000, color: "#38bdf8", label: "Diamond" },
+ { min: 3000, color: "#c0c0c0", label: "Platinum" },
+];
+
+export const renderStatPrimitivesPage = ({
+ player,
+ skin,
+ logo,
+ badge,
+ background,
+ user,
+}: DemoPageProps) => {
+ const bw = player.stats.bedwars;
+ const overall = bw.overall;
+ const fkdrSpark = seededSpark(player.uuid, overall.fkdr);
+ const wlrSpark = seededSpark(player.uuid, overall.wlr);
+
+ const statItems = [
+ { label: "FKDR", value: overall.fkdr, color: GAME_COLORS.bedwars },
+ { label: "WLR", value: overall.wlr, color: "#4ade80" },
+ { label: "KDR", value: overall.kdr, color: "#facc15" },
+ { label: "BBLR", value: overall.bblr, color: "#a78bfa" },
+ { label: "Final Kills", value: overall.finalKills, color: GAME_COLORS.bedwars },
+ { label: "Wins", value: overall.wins, color: "#4ade80" },
+ { label: "Losses", value: overall.losses, color: "#f87171" },
+ { label: "Deaths", value: overall.deaths, color: "#f87171" },
+ { label: "Beds Broken", value: overall.bedsBroken, color: "#fbbf24" },
+ ];
+
+ const canvas = render(
+
+
+
+
+
+ §lStat Grid
+
+
+
+ §lTrends
+
+
+
+
+
+
+
+ §lStatDelta Variants
+
+
+
+
+
+
+
+
+
+
+
+ §lBadge Variants
+
+ §fSolid
+ §7Outline
+ §8Soft
+
+
+ §aPositive
+ §cNegative
+ §eWarning
+
+
+
+ §lRank §f& §lPrestige
+
+
+
+
+
+
+
+
+ ,
+ getTheme(user)
+ );
+
+ return canvas;
+};
diff --git a/apps/discord-bot/src/commands/demo/pages/theming-kitchen.tsx b/apps/discord-bot/src/commands/demo/pages/theming-kitchen.tsx
new file mode 100644
index 000000000..1a72ef3a0
--- /dev/null
+++ b/apps/discord-bot/src/commands/demo/pages/theming-kitchen.tsx
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import {
+ Badge,
+ Container,
+ Footer,
+ GAME_COLORS,
+ Gauge,
+ Header,
+ SEMANTIC_COLORS,
+ StatGrid,
+} from "#components";
+import { autoContrast } from "#lib/auto-contrast";
+import { getTheme } from "#themes";
+import { render } from "@statsify/rendering";
+import type { DemoPageProps } from "../demo.command.js";
+
+const PALETTE: Array<{ label: string; color: string; accentKey: keyof typeof GAME_COLORS }> = [
+ { label: "§cBed§fWars", color: GAME_COLORS.bedwars, accentKey: "bedwars" },
+ { label: "§bSky§eWars", color: GAME_COLORS.skyWars, accentKey: "skyWars" },
+ { label: "§bDuels", color: GAME_COLORS.duels, accentKey: "duels" },
+ { label: "§5Quake", color: GAME_COLORS.quake, accentKey: "quake" },
+];
+
+const SWATCHES = [
+ SEMANTIC_COLORS.positive,
+ SEMANTIC_COLORS.negative,
+ SEMANTIC_COLORS.neutral,
+ SEMANTIC_COLORS.warning,
+ "#ef4444",
+ "#f97316",
+ "#eab308",
+ "#4ade80",
+ "#38bdf8",
+ "#6366f1",
+ "#d946ef",
+ "#ffffff",
+];
+
+export const renderThemingPage = ({
+ player,
+ skin,
+ logo,
+ badge,
+ background,
+ user,
+}: DemoPageProps) => {
+ const bw = player.stats.bedwars;
+ const overall = bw.overall;
+
+ const canvas = render(
+
+
+
+
+ {PALETTE.map(({ label, color, accentKey }) => {
+ const statItems = [
+ { label: "FKDR", value: overall.fkdr, color: GAME_COLORS[accentKey] },
+ { label: "WLR", value: overall.wlr, color: GAME_COLORS[accentKey] },
+ { label: "KDR", value: overall.kdr, color: GAME_COLORS[accentKey] },
+ ];
+
+ return (
+
+ {label}
+
+
+
+ );
+ })}
+
+
+ §lAuto-Contrast Swatches
+
+ {SWATCHES.map((hex) => {
+ const textColor = autoContrast(hex) === "#000000" ? "§0" : "§f";
+ return (
+
+ {textColor}A
+
+ );
+ })}
+
+
+
+ §lBadge Kitchen Sink
+
+ §7Small
+ §fMedium
+ §bLarge
+ §7Pill
+ §aGood
+ §cBad
+
+
+
+
+ ,
+ getTheme(user)
+ );
+
+ return canvas;
+};
diff --git a/apps/discord-bot/src/components/Badge.tsx b/apps/discord-bot/src/components/Badge.tsx
new file mode 100644
index 000000000..bc5eed72a
--- /dev/null
+++ b/apps/discord-bot/src/components/Badge.tsx
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { SEMANTIC_COLORS } from "./theme.js";
+import type { Spacing } from "@statsify/rendering";
+
+export type BadgeVariant = "solid" | "outline" | "soft";
+export type BadgeSize = "sm" | "md" | "lg";
+export type BadgeStatus = "positive" | "negative" | "neutral" | "warning";
+export type BadgeShape = "pill" | "tag";
+
+export interface BadgeProps {
+ children: string;
+ color?: JSX.IntrinsicElements["box"]["color"];
+ outline?: JSX.IntrinsicElements["box"]["outline"];
+ variant?: BadgeVariant;
+ size?: BadgeSize;
+ status?: BadgeStatus;
+ shape?: BadgeShape;
+ icon?: string;
+}
+
+const STATUS_HEX: Record = {
+ positive: SEMANTIC_COLORS.positive,
+ negative: SEMANTIC_COLORS.negative,
+ neutral: SEMANTIC_COLORS.neutral,
+ warning: SEMANTIC_COLORS.warning,
+};
+
+const PADDING: Record = {
+ sm: { top: 2, bottom: 2, left: 5, right: 5 },
+ md: { top: 3, bottom: 3, left: 8, right: 8 },
+ lg: { top: 4, bottom: 4, left: 12, right: 12 },
+};
+
+const TEXT_SIZE: Record = {
+ sm: 1.25,
+ md: 1.5,
+ lg: 1.75,
+};
+
+const RADIUS: Record = {
+ pill: 8,
+ tag: 3,
+};
+
+export const Badge = ({
+ children,
+ color,
+ outline,
+ variant = "solid",
+ size = "md",
+ status,
+ shape = "tag",
+ icon,
+}: BadgeProps) => {
+ const statusHex = status ? STATUS_HEX[status] : undefined;
+ const radius = RADIUS[shape];
+
+ let resolvedColor = color;
+ let resolvedOutline = outline;
+
+ if (statusHex) {
+ if (variant === "solid") {
+ resolvedColor = resolvedColor ?? `${statusHex}cc`;
+ } else if (variant === "soft") {
+ resolvedColor = resolvedColor ?? `${statusHex}33`;
+ } else {
+ resolvedColor = resolvedColor ?? "rgba(255,255,255,0.06)";
+ resolvedOutline = resolvedOutline ?? (statusHex as JSX.IntrinsicElements["box"]["outline"]);
+ }
+ } else if (variant === "outline") {
+ resolvedColor = resolvedColor ?? "rgba(255, 255, 255, 0.06)";
+ resolvedOutline = resolvedOutline ?? ("rgba(255,255,255,0.35)" as JSX.IntrinsicElements["box"]["outline"]);
+ } else if (variant === "soft") {
+ resolvedColor = resolvedColor ?? "rgba(255, 255, 255, 0.08)";
+ } else {
+ resolvedColor = resolvedColor ?? "rgba(255, 255, 255, 0.14)";
+ }
+
+ return (
+
+
+ {icon ?
+ (
+
+ {icon}
+
+ ) :
+ <>>}
+ {children}
+
+
+ );
+};
diff --git a/apps/discord-bot/src/components/BarChart.tsx b/apps/discord-bot/src/components/BarChart.tsx
new file mode 100644
index 000000000..ac8c554a8
--- /dev/null
+++ b/apps/discord-bot/src/components/BarChart.tsx
@@ -0,0 +1,189 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { DeferredGradient, useGradient } from "@statsify/rendering";
+
+export interface BarChartItem {
+ label: string;
+ value: number;
+ color?: JSX.IntrinsicElements["box"]["color"];
+ formattedValue?: string;
+ threshold?: number;
+ thresholdColor?: JSX.IntrinsicElements["box"]["color"];
+}
+
+export type BarChartOrientation = "vertical" | "horizontal";
+
+export interface BarChartProps {
+ items: BarChartItem[];
+ width?: JSX.Measurement;
+ max?: number;
+ sort?: boolean;
+ showValues?: boolean;
+ orientation?: BarChartOrientation;
+ goal?: number;
+ gradientFill?: boolean;
+}
+
+const resolveBarColor = (
+ item: BarChartItem,
+ gradientFill: boolean
+): JSX.IntrinsicElements["box"]["color"] | DeferredGradient => {
+ const base = item.threshold !== undefined && item.value >= item.threshold ?
+ (item.thresholdColor ?? "#4ade80") :
+ (item.color ?? "#9ca3af");
+
+ if (!gradientFill || typeof base !== "string") return base;
+
+ return useGradient("horizontal", [0, base], [1, `${base}88`]);
+};
+
+export const BarChart = ({
+ items,
+ width = "100%",
+ max,
+ sort = true,
+ showValues = true,
+ orientation = "vertical",
+ goal,
+ gradientFill = false,
+}: BarChartProps) => {
+ const sortedItems = sort ? [...items].sort((a, b) => b.value - a.value) : items;
+ const maximum = max ?? Math.max(1, ...sortedItems.map((item) => item.value));
+
+ if (orientation === "horizontal") {
+ return (
+
+ {sortedItems.map((item) => {
+ const percentage = item.value > 0 ?
+ Math.max(2, Math.min(100, Math.round((item.value / maximum) * 100))) :
+ 0;
+ const barColor = resolveBarColor(item, gradientFill);
+
+ return (
+
+
+ {`§7${item.label}`}
+
+
+
+ {goal === undefined ?
+ <>> :
+ (
+
+ )}
+
+ {showValues ?
+ (
+
+ {item.formattedValue ?? item.value.toLocaleString()}
+
+ ) :
+ <>>}
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+ {sortedItems.map((item) => {
+ const percentage = item.value > 0 ?
+ Math.max(2, Math.min(100, Math.round((item.value / maximum) * 100))) :
+ 0;
+ const barColor = resolveBarColor(item, gradientFill);
+
+ return (
+
+
+
+ {`§7${item.label}`}
+
+
+ {showValues ?
+ (
+
+ {item.formattedValue ?? item.value.toLocaleString()}
+
+ ) :
+ <>>}
+
+
+
+ {goal === undefined ?
+ <>> :
+ (
+
+ )}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/apps/discord-bot/src/components/Gauge.tsx b/apps/discord-bot/src/components/Gauge.tsx
new file mode 100644
index 000000000..71a0ae307
--- /dev/null
+++ b/apps/discord-bot/src/components/Gauge.tsx
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface GaugeProps {
+ value: number;
+ min?: number;
+ max?: number;
+ fillColor?: JSX.IntrinsicElements["arc"]["fillColor"];
+ trackColor?: JSX.IntrinsicElements["arc"]["trackColor"];
+ label?: string;
+ sublabel?: string;
+ size?: number;
+}
+
+export const Gauge = ({
+ value,
+ min = 0,
+ max = 100,
+ fillColor = "#9ca3af",
+ trackColor = "rgba(255,255,255,0.12)",
+ label,
+ sublabel,
+ size = 80,
+}: GaugeProps) => (
+
+
+ {sublabel ?
+
{`§7${sublabel}`} :
+ <>>}
+
+);
diff --git a/apps/discord-bot/src/components/Graph.tsx b/apps/discord-bot/src/components/Graph.tsx
new file mode 100644
index 000000000..c3857fbe5
--- /dev/null
+++ b/apps/discord-bot/src/components/Graph.tsx
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface GraphPoint {
+ label: string;
+ value: number;
+}
+
+export interface GraphSeries {
+ points: GraphPoint[];
+ color?: JSX.IntrinsicElements["graph"]["color"];
+ fillColor?: JSX.IntrinsicElements["graph"]["fillColor"];
+ lineWidth?: number;
+}
+
+export interface GraphBand {
+ min: number;
+ max: number;
+ color: string;
+}
+
+export interface GraphMarker {
+ index: number;
+ label?: string;
+ color?: JSX.IntrinsicElements["graph"]["color"];
+ radius?: number;
+}
+
+export interface GraphProps {
+ points?: GraphPoint[];
+ series?: GraphSeries[];
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ color?: JSX.IntrinsicElements["graph"]["color"];
+ fillColor?: JSX.IntrinsicElements["graph"]["fillColor"];
+ referenceValue?: number;
+ min?: number;
+ max?: number;
+ baselineZero?: boolean;
+ smooth?: boolean;
+ showLabels?: boolean;
+ bands?: GraphBand[];
+ markers?: GraphMarker[];
+ showLastValue?: boolean;
+ lastPointColor?: JSX.IntrinsicElements["graph"]["color"];
+}
+
+export const Graph = ({
+ points = [],
+ series,
+ width = "100%",
+ height = 96,
+ color = "#9ca3af",
+ fillColor = "rgba(156, 163, 175, 0.14)",
+ referenceValue,
+ min,
+ max,
+ baselineZero,
+ smooth = true,
+ showLabels = true,
+ bands,
+ markers,
+ showLastValue,
+ lastPointColor,
+}: GraphProps) => {
+ const allPoints = [
+ ...points,
+ ...(series ?? []).flatMap((s) => s.points),
+ ];
+ const labelPoints = allPoints.length <= 5 ? points : [points[0], points.at(-1)].filter(Boolean) as GraphPoint[];
+
+ const extraSeries = series?.map((s) => ({
+ data: s.points.map((p) => p.value),
+ color: s.color ?? "#9ca3af",
+ fillColor: s.fillColor,
+ lineWidth: s.lineWidth,
+ }));
+
+ return (
+
+
p.value)}
+ min={min}
+ max={max}
+ baselineZero={baselineZero}
+ color={color}
+ fillColor={fillColor}
+ referenceValue={referenceValue}
+ smooth={smooth}
+ series={extraSeries}
+ bands={bands}
+ markers={markers}
+ showLastPoint={showLastValue}
+ lastPointColor={lastPointColor}
+ />
+ {showLabels && labelPoints.length ?
+ (
+
+ {labelPoints.map((point) => (
+
+ {`§7${point.label}`}
+
+ ))}
+
+ ) :
+ <>>}
+
+ );
+};
diff --git a/apps/discord-bot/src/components/HeatmapChart.tsx b/apps/discord-bot/src/components/HeatmapChart.tsx
new file mode 100644
index 000000000..c64822209
--- /dev/null
+++ b/apps/discord-bot/src/components/HeatmapChart.tsx
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface HeatmapChartProps {
+ cells: number[];
+ cols: number;
+ rowLabels?: string[];
+ colLabels?: string[];
+ highColor?: JSX.IntrinsicElements["heatmap"]["highColor"];
+ cellSize?: number;
+ cellGap?: number;
+}
+
+export const HeatmapChart = ({
+ cells,
+ cols,
+ rowLabels,
+ colLabels,
+ highColor = "#4ade80",
+ cellSize = 12,
+ cellGap = 2,
+}: HeatmapChartProps) => (
+
+ {colLabels ?
+ (
+
+ {colLabels.map((label) => (
+
+ {`§8${label}`}
+
+ ))}
+
+ ) :
+ <>>}
+
+ {rowLabels ?
+ (
+
+ {rowLabels.map((label) => (
+
+ {`§8${label}`}
+
+ ))}
+
+ ) :
+ <>>}
+
+
+
+);
diff --git a/apps/discord-bot/src/components/LeaderboardRow.tsx b/apps/discord-bot/src/components/LeaderboardRow.tsx
new file mode 100644
index 000000000..031d37851
--- /dev/null
+++ b/apps/discord-bot/src/components/LeaderboardRow.tsx
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface LeaderboardRowProps {
+ rank: number;
+ name: string;
+ value: string | number;
+ accentColor?: string;
+ width?: JSX.Measurement;
+}
+
+const rankColor = (rank: number): string => {
+ if (rank === 1) return "§#ffd700";
+ if (rank === 2) return "§#c0c0c0";
+ if (rank === 3) return "§#cd7f32";
+ return "§7";
+};
+
+const accentMc = (color: string) =>
+ color.startsWith("#") ? `§#${color.slice(1)}` : color;
+
+export const LeaderboardRow = ({
+ rank,
+ name,
+ value,
+ accentColor = "#9ca3af",
+ width = "100%",
+}: LeaderboardRowProps) => (
+
+
+ {`${rankColor(rank)}#${rank}`}
+
+ {`§f${name}`}
+
+
+ {`${accentMc(accentColor)}${typeof value === "number" ? value.toLocaleString() : value}`}
+
+
+);
diff --git a/apps/discord-bot/src/components/PrestigeIcon.tsx b/apps/discord-bot/src/components/PrestigeIcon.tsx
new file mode 100644
index 000000000..35b0775d4
--- /dev/null
+++ b/apps/discord-bot/src/components/PrestigeIcon.tsx
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface PrestigeIconThreshold {
+ min: number;
+ color: string;
+ label?: string;
+}
+
+export interface PrestigeIconProps {
+ value: number;
+ thresholds: PrestigeIconThreshold[];
+ size?: number;
+ showLabel?: boolean;
+}
+
+const DEFAULT_COLOR = "#9ca3af";
+
+export const PrestigeIcon = ({
+ value,
+ thresholds,
+ size = 32,
+ showLabel = true,
+}: PrestigeIconProps) => {
+ const sorted = [...thresholds].sort((a, b) => b.min - a.min);
+ const match = sorted.find((t) => value >= t.min);
+ const color = match?.color ?? DEFAULT_COLOR;
+ const label = match?.label ?? String(value);
+
+ return (
+
+ t.min))}
+ fillColor={color}
+ trackColor={`${color}33`}
+ width={size}
+ height={size}
+ trackWidth={5}
+ fillWidth={5}
+ centerLabel={showLabel ? label : undefined}
+ centerLabelColor={color}
+ centerLabelSize={Math.max(8, Math.round(size * 0.22))}
+ />
+
+ );
+};
diff --git a/apps/discord-bot/src/components/RadarChart.tsx b/apps/discord-bot/src/components/RadarChart.tsx
new file mode 100644
index 000000000..1b6cb605d
--- /dev/null
+++ b/apps/discord-bot/src/components/RadarChart.tsx
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface RadarChartAxis {
+ label: string;
+ value: number;
+ max?: number;
+}
+
+export interface RadarChartProps {
+ axes: RadarChartAxis[];
+ color?: string;
+ fillColor?: string;
+ secondarySeries?: RadarChartAxis[];
+ secondaryColor?: string;
+ secondaryFillColor?: string;
+ size?: number;
+}
+
+type IntrinsicSeries = JSX.IntrinsicElements["radar"]["series"];
+
+export const RadarChart = ({
+ axes,
+ color = "#9ca3af",
+ fillColor = "rgba(156, 163, 175, 0.2)",
+ secondarySeries,
+ secondaryColor = "#4ade80",
+ secondaryFillColor = "rgba(74, 222, 128, 0.15)",
+ size = 160,
+}: RadarChartProps) => {
+ const normalizeAxes = (axisArr: RadarChartAxis[]) =>
+ axisArr.map((a, i) => {
+ const globalMax = axes[i]?.max ?? Math.max(1, ...axes.map((x) => x.value));
+ return Math.max(0, Math.min(1, a.value / globalMax));
+ });
+
+ const primary = { values: normalizeAxes(axes), color, fillColor };
+
+ const series: IntrinsicSeries = secondarySeries ?
+ [primary, { values: normalizeAxes(secondarySeries), color: secondaryColor, fillColor: secondaryFillColor }] :
+ [primary];
+
+ return (
+ a.label)}
+ series={series}
+ width={size}
+ height={size}
+ />
+ );
+};
diff --git a/apps/discord-bot/src/components/RankTag.tsx b/apps/discord-bot/src/components/RankTag.tsx
new file mode 100644
index 000000000..4a803b8bf
--- /dev/null
+++ b/apps/discord-bot/src/components/RankTag.tsx
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface RankTagProps {
+ rank: string;
+ size?: number;
+}
+
+export const RankTag = ({ rank, size = 1.75 }: RankTagProps) => (
+
+ {rank}
+
+);
diff --git a/apps/discord-bot/src/components/StatDelta.tsx b/apps/discord-bot/src/components/StatDelta.tsx
new file mode 100644
index 000000000..166f2cf0f
--- /dev/null
+++ b/apps/discord-bot/src/components/StatDelta.tsx
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { Badge } from "./Badge.js";
+
+export interface StatDeltaProps {
+ value: number;
+ format?: (value: number) => string;
+ inverseGood?: boolean;
+ invertColor?: boolean;
+ neutralZone?: number;
+ sparkline?: number[];
+}
+
+export const StatDelta = ({
+ value,
+ format = (v) => v.toLocaleString(undefined, { maximumFractionDigits: 2 }),
+ inverseGood = false,
+ invertColor,
+ neutralZone,
+ sparkline,
+}: StatDeltaProps) => {
+ const effectiveInverse = inverseGood || invertColor || false;
+ const rounded = Math.round(value * 100) / 100;
+ const absRounded = Math.abs(rounded);
+ const isNeutral = neutralZone !== undefined && absRounded <= neutralZone;
+ const isPositive = !isNeutral && rounded > 0;
+ const isNegative = !isNeutral && rounded < 0;
+ const isGood = isPositive !== effectiveInverse;
+ const color = isPositive || isNegative ? (isGood ? "§a" : "§c") : "§7";
+ const sign = isPositive ? "+" : "";
+
+ return (
+
+ {sparkline && sparkline.length > 1 ?
+ (
+
+ ) :
+ <>>}
+
+ {`${color}${sign}${format(rounded)}`}
+
+
+ );
+};
diff --git a/apps/discord-bot/src/components/StatGrid.tsx b/apps/discord-bot/src/components/StatGrid.tsx
new file mode 100644
index 000000000..059ba274e
--- /dev/null
+++ b/apps/discord-bot/src/components/StatGrid.tsx
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface StatGridItem {
+ label: string;
+ value: string | number;
+ color?: string;
+}
+
+export interface StatGridProps {
+ items: StatGridItem[];
+ columns?: number;
+ width?: JSX.Measurement;
+}
+
+const DEFAULT_COLOR = "#9ca3af";
+
+function chunk(arr: T[], n: number): T[][] {
+ const result: T[][] = [];
+ for (let i = 0; i < arr.length; i += n) result.push(arr.slice(i, i + n));
+ return result;
+}
+
+export const StatGrid = ({
+ items,
+ columns = 3,
+ width = "100%",
+}: StatGridProps) => {
+ const rows = chunk(items, columns);
+
+ return (
+
+ {rows.map((row) => (
+
+ {row.map((item) => {
+ const hex = item.color ?? DEFAULT_COLOR;
+ const mcColor = hex.startsWith("#") ? `§#${hex.slice(1)}` : hex;
+
+ return (
+
+
+ {`§7${item.label}`}
+
+
+ {`${mcColor}${typeof item.value === "number" ? item.value.toLocaleString() : item.value}`}
+
+
+ );
+ })}
+
+ ))}
+
+ );
+};
diff --git a/apps/discord-bot/src/components/Trend.tsx b/apps/discord-bot/src/components/Trend.tsx
new file mode 100644
index 000000000..e32983ec4
--- /dev/null
+++ b/apps/discord-bot/src/components/Trend.tsx
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { StatDelta } from "./StatDelta.js";
+
+export interface TrendProps {
+ label: string;
+ value: number;
+ delta?: number;
+ sparkline?: number[];
+ format?: (v: number) => string;
+ inverseGood?: boolean;
+ accentColor?: string;
+ width?: JSX.Measurement;
+}
+
+export const Trend = ({
+ label,
+ value,
+ delta,
+ sparkline,
+ format,
+ inverseGood,
+ accentColor = "#9ca3af",
+ width = "100%",
+}: TrendProps) => {
+ const displayValue = format ? format(value) : value.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ const mcColor = accentColor.startsWith("#") ? `§#${accentColor.slice(1)}` : accentColor;
+
+ return (
+
+ {`§7${label}`}
+
+
{`${mcColor}${displayValue}`}
+ {delta === undefined ?
+ (sparkline && sparkline.length > 1 ?
+ (
+
+ ) :
+ <>>) :
+ (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/discord-bot/src/components/VersusPanel.tsx b/apps/discord-bot/src/components/VersusPanel.tsx
new file mode 100644
index 000000000..3f936b312
--- /dev/null
+++ b/apps/discord-bot/src/components/VersusPanel.tsx
@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export interface VersusStatRow {
+ label: string;
+ leftValue: number | string;
+ rightValue: number | string;
+ higherIsBetter?: boolean;
+ format?: (v: number) => string;
+}
+
+export interface VersusPlayer {
+ name: string;
+ stats: VersusStatRow[];
+}
+
+export interface VersusPanelProps {
+ left: VersusPlayer;
+ right: VersusPlayer;
+ width?: JSX.Measurement;
+}
+
+const compareValues = (
+ left: number | string,
+ right: number | string,
+ higherIsBetter = true
+): "left" | "right" | "tie" => {
+ if (typeof left !== "number" || typeof right !== "number") return "tie";
+ if (left === right) return "tie";
+ return (left > right) === higherIsBetter ? "left" : "right";
+};
+
+const formatVal = (v: number | string, format?: (n: number) => string) =>
+ typeof v === "number" ?
+ (format ? format(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 })) :
+ v;
+
+const StatRow = ({ row, rightRow }: { row: VersusStatRow; rightRow: VersusStatRow | undefined }) => {
+ const rightValue = rightRow?.rightValue ?? row.rightValue;
+ const winner = compareValues(row.leftValue, rightValue, row.higherIsBetter);
+ const leftColor = winner === "left" ? "§a" : (winner === "right" ? "§c" : "§7");
+ const rightColor = winner === "right" ? "§a" : (winner === "left" ? "§c" : "§7");
+
+ return (
+
+
+
+ {`${leftColor}${formatVal(row.leftValue, row.format)}`}
+
+
+
+ {`§7${row.label}`}
+
+
+
+
+ {`${rightColor}${formatVal(rightValue, row.format)}`}
+
+
+
+ );
+};
+
+export const VersusPanel = ({
+ left,
+ right,
+ width = "100%",
+}: VersusPanelProps) => {
+ const header = (
+
+ );
+
+ const rows = left.stats.map((row) => (
+ r.label === row.label)} />
+ ));
+
+ return (
+
+ {[header, ...rows]}
+
+ );
+};
diff --git a/apps/discord-bot/src/components/index.ts b/apps/discord-bot/src/components/index.ts
index bdd58d484..2497a6788 100644
--- a/apps/discord-bot/src/components/index.ts
+++ b/apps/discord-bot/src/components/index.ts
@@ -7,15 +7,29 @@
*/
export * from "./Background.js";
+export * from "./Badge.js";
+export * from "./BarChart.js";
export * from "./Container.js";
export * from "./Footer.js";
+export * from "./Gauge.js";
+export * from "./Graph.js";
export * from "./Header/index.js";
+export * from "./HeatmapChart.js";
export * from "./Historical/index.js";
export * from "./If.js";
+export * from "./LeaderboardRow.js";
export * from "./List.js";
export * from "./Multiline.js";
+export * from "./PrestigeIcon.js";
export * from "./ProgressBar.js";
+export * from "./RadarChart.js";
+export * from "./RankTag.js";
export * from "./Sidebar.js";
export * from "./Skin.js";
+export * from "./StatDelta.js";
+export * from "./StatGrid.js";
export * from "./Table/index.js";
export * from "./GameList.js";
+export * from "./Trend.js";
+export * from "./VersusPanel.js";
+export * from "./theme.js";
diff --git a/apps/discord-bot/src/components/theme.ts b/apps/discord-bot/src/components/theme.ts
new file mode 100644
index 000000000..8f772c2e2
--- /dev/null
+++ b/apps/discord-bot/src/components/theme.ts
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export const SEMANTIC_COLORS = {
+ positive: "#4ade80",
+ negative: "#f87171",
+ neutral: "#9ca3af",
+ warning: "#fbbf24",
+} as const;
+
+export const GAME_COLORS = {
+ arcade: "#fbbf24",
+ arenaBrawl: "#f59e0b",
+ bedwars: "#ef4444",
+ blitzSg: "#22c55e",
+ buildBattle: "#d946ef",
+ copsAndCrims: "#f59e0b",
+ duels: "#6366f1",
+ general: "#64748b",
+ megaWalls: "#6b7280",
+ murderMystery: "#dc2626",
+ paintball: "#38bdf8",
+ pit: "#eab308",
+ quake: "#a855f7",
+ skyWars: "#38bdf8",
+ smashHeroes: "#d946ef",
+ speedUhc: "#f59e0b",
+ tntGames: "#ef4444",
+ turboKartRacers: "#4ade80",
+ uhc: "#f59e0b",
+ vampirez: "#dc2626",
+ walls: "#eab308",
+ warlords: "#ef4444",
+ woolGames: "#3b82f6",
+} as const;
+
+export type GameColorKey = keyof typeof GAME_COLORS;
diff --git a/apps/discord-bot/src/lib/auto-contrast.ts b/apps/discord-bot/src/lib/auto-contrast.ts
new file mode 100644
index 000000000..933bd06e7
--- /dev/null
+++ b/apps/discord-bot/src/lib/auto-contrast.ts
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { hexToRgb } from "@statsify/rendering";
+
+const linearize = (c: number) => {
+ const s = c / 255;
+ return s <= 0.039_28 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+};
+
+const relativeLuminance = (r: number, g: number, b: number) =>
+ 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
+
+export const autoContrast = (hex: string): "#000000" | "#ffffff" => {
+ const [r, g, b] = hexToRgb(hex);
+ return relativeLuminance(r, g, b) > 0.179 ? "#000000" : "#ffffff";
+};
+
+export const mcAutoContrast = (hex: string): "§0" | "§f" =>
+ autoContrast(hex) === "#000000" ? "§0" : "§f";
diff --git a/apps/discord-bot/src/lib/format-number.ts b/apps/discord-bot/src/lib/format-number.ts
new file mode 100644
index 000000000..167159db9
--- /dev/null
+++ b/apps/discord-bot/src/lib/format-number.ts
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+export const compact = (n: number): string => {
+ if (!Number.isFinite(n)) return "0";
+ const abs = Math.abs(n);
+ const sign = n < 0 ? "-" : "";
+ if (abs >= 1_000_000_000) return `${sign}${(abs / 1_000_000_000).toFixed(2).replace(/\.?0+$/, "")}B`;
+ if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(2).replace(/\.?0+$/, "")}M`;
+ if (abs >= 1000) return `${sign}${(abs / 1000).toFixed(1).replace(/\.0$/, "")}k`;
+ return `${sign}${abs}`;
+};
+
+export const fixedDecimal = (n: number, digits = 2): string => {
+ if (!Number.isFinite(n)) return "0";
+ return n.toFixed(digits);
+};
+
+export const ratioFormat = (n: number, digits = 2): string => fixedDecimal(n, digits);
diff --git a/packages/rendering/src/intrinsics/Arc.ts b/packages/rendering/src/intrinsics/Arc.ts
new file mode 100644
index 000000000..263fe72ef
--- /dev/null
+++ b/packages/rendering/src/intrinsics/Arc.ts
@@ -0,0 +1,131 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export interface ArcRenderProps {
+ value: number;
+ min: number;
+ max: number;
+ startAngle: number;
+ endAngle: number;
+ trackColor: JSX.Fill;
+ fillColor: JSX.Fill;
+ trackWidth: number;
+ fillWidth: number;
+ lineCap: CanvasLineCap;
+ centerLabel?: string;
+ centerLabelColor: JSX.Fill;
+ centerLabelSize: number;
+}
+
+export interface ArcProps extends Partial> {
+ value: number;
+ min?: number;
+ max?: number;
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ value,
+ min = 0,
+ max = 100,
+ startAngle = -Math.PI * 0.75,
+ endAngle = Math.PI * 0.75,
+ trackColor = "rgba(255, 255, 255, 0.12)",
+ fillColor = "#9ca3af",
+ trackWidth = 8,
+ fillWidth = 8,
+ lineCap = "round",
+ centerLabel,
+ centerLabelColor = "#ffffff",
+ centerLabelSize = 14,
+ width = 80,
+ height = 80,
+ margin = 4,
+ location = "center",
+ align = "center",
+}) => ({
+ dimension: { width, height, margin },
+ style: { location, direction: "row", align },
+ props: {
+ value,
+ min,
+ max,
+ startAngle,
+ endAngle,
+ trackColor,
+ fillColor,
+ trackWidth,
+ fillWidth,
+ lineCap,
+ centerLabel,
+ centerLabelColor,
+ centerLabelSize,
+ },
+ children: undefined,
+});
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ {
+ value,
+ min,
+ max,
+ startAngle,
+ endAngle,
+ trackColor,
+ fillColor,
+ trackWidth,
+ fillWidth,
+ lineCap,
+ centerLabel,
+ centerLabelColor,
+ centerLabelSize,
+ },
+ { x, y, width, height }
+) => {
+ const cx = x + width / 2;
+ const cy = y + height / 2;
+ const radius = Math.max(1, Math.min(width, height) / 2 - Math.max(trackWidth, fillWidth) / 2 - 2);
+
+ const sweep = endAngle - startAngle;
+ const clamped = Math.max(0, Math.min(1, (value - min) / (max - min || 1)));
+
+ ctx.save();
+ ctx.lineCap = lineCap;
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, startAngle, endAngle);
+ ctx.strokeStyle = trackColor;
+ ctx.lineWidth = trackWidth;
+ ctx.stroke();
+
+ if (clamped > 0) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, startAngle, startAngle + sweep * clamped);
+ ctx.strokeStyle = fillColor;
+ ctx.lineWidth = fillWidth;
+ ctx.stroke();
+ }
+
+ if (centerLabel) {
+ ctx.fillStyle = centerLabelColor;
+ ctx.font = `bold ${centerLabelSize}px sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(centerLabel, cx, cy);
+ }
+
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/Donut.ts b/packages/rendering/src/intrinsics/Donut.ts
new file mode 100644
index 000000000..5165b6e72
--- /dev/null
+++ b/packages/rendering/src/intrinsics/Donut.ts
@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export interface DonutSegment {
+ value: number;
+ color: JSX.Fill;
+ label?: string;
+}
+
+export interface DonutRenderProps {
+ segments: DonutSegment[];
+ innerRadius: number;
+ gap: number;
+ startAngle: number;
+ labelColor: JSX.Fill;
+ labelSize: number;
+}
+
+export interface DonutProps extends Partial> {
+ segments: DonutSegment[];
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ segments,
+ innerRadius = 0.6,
+ gap = 0.02,
+ startAngle = -Math.PI / 2,
+ labelColor = "#ffffff",
+ labelSize = 11,
+ width = 120,
+ height = 120,
+ margin = 4,
+ location = "center",
+ align = "center",
+}) => ({
+ dimension: { width, height, margin },
+ style: { location, direction: "row", align },
+ props: { segments, innerRadius, gap, startAngle, labelColor, labelSize },
+ children: undefined,
+});
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ { segments, innerRadius, gap, startAngle, labelColor, labelSize },
+ { x, y, width, height }
+) => {
+ const cx = x + width / 2;
+ const cy = y + height / 2;
+ const outerR = Math.max(1, Math.min(width, height) / 2 - 2);
+ const innerR = outerR * Math.max(0, Math.min(0.99, innerRadius));
+
+ const total = segments.reduce((sum, s) => sum + Math.max(0, s.value), 0);
+ if (total === 0) return;
+
+ ctx.save();
+
+ let angle = startAngle;
+
+ for (const seg of segments) {
+ const fraction = Math.max(0, seg.value) / total;
+ const sweep = Math.max(0, Math.PI * 2 * fraction - gap);
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, outerR, angle + gap / 2, angle + gap / 2 + sweep);
+ if (innerR > 0) {
+ ctx.arc(cx, cy, innerR, angle + gap / 2 + sweep, angle + gap / 2, true);
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ ctx.closePath();
+ ctx.fillStyle = seg.color;
+ ctx.fill();
+
+ if (seg.label && fraction > 0.06) {
+ const midAngle = angle + gap / 2 + sweep / 2;
+ const labelR = innerR + (outerR - innerR) * 0.5;
+ ctx.fillStyle = labelColor;
+ ctx.font = `bold ${labelSize}px sans-serif`;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(seg.label, cx + labelR * Math.cos(midAngle), cy + labelR * Math.sin(midAngle));
+ }
+
+ angle += Math.PI * 2 * fraction;
+ }
+
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/Gradient.ts b/packages/rendering/src/intrinsics/Gradient.ts
new file mode 100644
index 000000000..f86bd79f6
--- /dev/null
+++ b/packages/rendering/src/intrinsics/Gradient.ts
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+import type { GradientColor } from "#hooks";
+
+export type GradientType = "linear-h" | "linear-v" | "radial";
+
+export interface GradientRenderProps {
+ type: GradientType;
+ colors: GradientColor[];
+ opacity: number;
+}
+
+export interface GradientProps extends Partial {
+ colors: GradientColor[];
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ colors,
+ type = "linear-v",
+ opacity = 1,
+ width = "100%",
+ height = "100%",
+ margin = 0,
+ location = "left",
+ align = "left",
+}) => ({
+ dimension: { width, height, margin },
+ style: { location, direction: "row", align },
+ props: { type, colors, opacity },
+ children: undefined,
+});
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ { type, colors, opacity },
+ { x, y, width, height }
+) => {
+ if (!colors.length) return;
+
+ ctx.save();
+ ctx.globalAlpha = opacity;
+
+ let gradient;
+ if (type === "radial") {
+ const cx = x + width / 2;
+ const cy = y + height / 2;
+ const r = Math.min(width, height) / 2;
+ gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
+ } else if (type === "linear-h") {
+ gradient = ctx.createLinearGradient(x, y, x + width, y);
+ } else {
+ gradient = ctx.createLinearGradient(x, y, x, y + height);
+ }
+
+ for (const [offset, color] of colors) gradient.addColorStop(offset, color);
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(x, y, width, height);
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/Graph.ts b/packages/rendering/src/intrinsics/Graph.ts
new file mode 100644
index 000000000..c2235f025
--- /dev/null
+++ b/packages/rendering/src/intrinsics/Graph.ts
@@ -0,0 +1,303 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export interface GraphSeriesData {
+ data: number[];
+ color: JSX.Fill;
+ fillColor?: JSX.Fill;
+ lineWidth?: number;
+}
+
+export interface GraphBand {
+ min: number;
+ max: number;
+ color: JSX.Fill;
+}
+
+export interface GraphMarker {
+ index: number;
+ label?: string;
+ color?: JSX.Fill;
+ radius?: number;
+}
+
+export interface GraphRenderProps {
+ data: number[];
+ min?: number;
+ max?: number;
+ baselineZero?: boolean;
+ color: JSX.Fill;
+ fillColor?: JSX.Fill;
+ referenceColor?: JSX.Fill;
+ referenceValue?: number;
+ lineWidth: number;
+ pointRadius: number;
+ smooth: boolean;
+ padding: number;
+ series?: GraphSeriesData[];
+ bands?: GraphBand[];
+ markers?: GraphMarker[];
+ showLastPoint?: boolean;
+ lastPointColor?: JSX.Fill;
+}
+
+export interface GraphProps extends Partial> {
+ data?: number[];
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+const resolveNumber = (value = 0) => Number.isFinite(value) ? value : 0;
+
+const resolveFill = (fill: JSX.Fill | undefined, fallback: JSX.Fill) => fill ?? fallback;
+
+export const component: JSX.RawFC = ({
+ data = [],
+ width,
+ height = 100,
+ margin = 4,
+ location = "center",
+ align = "left",
+ min,
+ max,
+ baselineZero,
+ color = "#9ca3af",
+ fillColor,
+ referenceColor = "rgba(255, 255, 255, 0.24)",
+ referenceValue,
+ lineWidth = 2,
+ pointRadius = data.length <= 6 ? 2 : 0,
+ smooth = false,
+ padding = 8,
+ series,
+ bands,
+ markers,
+ showLastPoint,
+ lastPointColor,
+}) => ({
+ dimension: {
+ width,
+ height,
+ margin,
+ },
+ style: { location, direction: "row", align },
+ props: {
+ data,
+ min,
+ max,
+ baselineZero,
+ color,
+ fillColor,
+ referenceColor,
+ referenceValue,
+ lineWidth,
+ pointRadius,
+ smooth,
+ padding,
+ series,
+ bands,
+ markers,
+ showLastPoint,
+ lastPointColor,
+ },
+ children: undefined,
+});
+
+const buildPoints = (
+ values: number[],
+ computedMin: number,
+ range: number,
+ left: number,
+ bottom: number,
+ graphWidth: number,
+ graphHeight: number
+) =>
+ values.map((value, index) => {
+ const progress = values.length === 1 ? 0.5 : index / (values.length - 1);
+ return {
+ x: left + graphWidth * progress,
+ y: bottom - ((value - computedMin) / range) * graphHeight,
+ };
+ });
+
+const tracePath = (
+ ctx: CanvasRenderingContext2D,
+ points: { x: number; y: number }[],
+ smooth: boolean
+) => {
+ ctx.beginPath();
+ for (const [index, point] of points.entries()) {
+ if (index === 0) {
+ ctx.moveTo(point.x, point.y);
+ continue;
+ }
+ const previous = points[index - 1];
+ if (smooth) {
+ const controlX = (previous.x + point.x) / 2;
+ ctx.bezierCurveTo(controlX, previous.y, controlX, point.y, point.x, point.y);
+ } else {
+ ctx.lineTo(point.x, point.y);
+ }
+ }
+};
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ {
+ data,
+ min,
+ max,
+ baselineZero,
+ color,
+ fillColor,
+ referenceColor,
+ referenceValue,
+ lineWidth,
+ pointRadius,
+ smooth,
+ padding,
+ series,
+ bands,
+ markers,
+ showLastPoint,
+ lastPointColor,
+ },
+ { x, y, width, height }
+) => {
+ const allData = [
+ ...data.map(resolveNumber),
+ ...(series ?? []).flatMap((s) => s.data.map(resolveNumber)),
+ ];
+
+ if (!allData.length) return;
+
+ const rawMin = min ?? Math.min(...allData);
+ const computedMin = baselineZero ? Math.min(0, rawMin) : rawMin;
+ const computedMax = max ?? Math.max(...allData);
+ const range = computedMax - computedMin || 1;
+
+ const left = Math.round(x + padding);
+ const top = Math.round(y + padding);
+ const graphWidth = Math.max(1, Math.round(width - padding * 2));
+ const graphHeight = Math.max(1, Math.round(height - padding * 2));
+ const bottom = top + graphHeight;
+
+ const primaryValues = data.map(resolveNumber);
+
+ const mkPoints = (values: number[]) =>
+ buildPoints(values, computedMin, range, left, bottom, graphWidth, graphHeight);
+
+ if (bands?.length) {
+ ctx.save();
+ for (const band of bands) {
+ const bandTop = bottom - ((band.max - computedMin) / range) * graphHeight;
+ const bandBottom = bottom - ((band.min - computedMin) / range) * graphHeight;
+ if (bandBottom < top || bandTop > bottom) continue;
+ ctx.fillStyle = band.color;
+ ctx.fillRect(left, Math.max(top, bandTop), graphWidth, Math.min(bottom, bandBottom) - Math.max(top, bandTop));
+ }
+ ctx.restore();
+ }
+
+ if (typeof referenceValue === "number") {
+ const referenceY = bottom - ((referenceValue - computedMin) / range) * graphHeight;
+ if (referenceY >= top && referenceY <= bottom) {
+ ctx.save();
+ ctx.strokeStyle = resolveFill(referenceColor, "rgba(255, 255, 255, 0.24)");
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.moveTo(left, referenceY);
+ ctx.lineTo(left + graphWidth, referenceY);
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ const renderSeries = (
+ values: number[],
+ seriesColor: JSX.Fill,
+ seriesFill: JSX.Fill | undefined,
+ seriesLineWidth: number
+ ) => {
+ if (!values.length) return;
+ const pts = mkPoints(values);
+
+ if (seriesFill) {
+ tracePath(ctx, pts, smooth);
+ ctx.lineTo(left + graphWidth, bottom);
+ ctx.lineTo(left, bottom);
+ ctx.closePath();
+ ctx.fillStyle = seriesFill;
+ ctx.fill();
+ }
+
+ tracePath(ctx, pts, smooth);
+ ctx.strokeStyle = resolveFill(seriesColor, "#9ca3af");
+ ctx.lineWidth = seriesLineWidth;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.stroke();
+ };
+
+ for (const s of series ?? []) {
+ renderSeries(
+ s.data.map(resolveNumber),
+ s.color,
+ s.fillColor,
+ s.lineWidth ?? lineWidth
+ );
+ }
+
+ renderSeries(primaryValues, color, fillColor, lineWidth);
+
+ if (pointRadius && primaryValues.length) {
+ const pts = mkPoints(primaryValues);
+ ctx.fillStyle = resolveFill(color, "#9ca3af");
+ for (const point of pts) {
+ ctx.beginPath();
+ ctx.arc(point.x, point.y, pointRadius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ if (markers?.length && primaryValues.length) {
+ const pts = mkPoints(primaryValues);
+ for (const marker of markers) {
+ const pt = pts[marker.index];
+ if (!pt) continue;
+ ctx.beginPath();
+ const r = marker.radius ?? pointRadius + 2;
+ ctx.arc(pt.x, pt.y, Math.max(2, r), 0, Math.PI * 2);
+ ctx.fillStyle = marker.color ?? color;
+ ctx.fill();
+ }
+ }
+
+ if (showLastPoint && primaryValues.length) {
+ const pts = mkPoints(primaryValues);
+ const last = pts.at(-1)!;
+ const dotColor = lastPointColor ?? color;
+ ctx.beginPath();
+ ctx.arc(last.x, last.y, 4, 0, Math.PI * 2);
+ ctx.fillStyle = dotColor;
+ ctx.fill();
+ const labelValue = primaryValues.at(-1)!;
+ ctx.fillStyle = dotColor;
+ ctx.font = "bold 10px sans-serif";
+ ctx.textAlign = "right";
+ ctx.textBaseline = "bottom";
+ ctx.fillText(labelValue.toFixed(2), last.x - 2, last.y - 4);
+ }
+};
diff --git a/packages/rendering/src/intrinsics/HeatmapIntrinsic.ts b/packages/rendering/src/intrinsics/HeatmapIntrinsic.ts
new file mode 100644
index 000000000..cb8060b56
--- /dev/null
+++ b/packages/rendering/src/intrinsics/HeatmapIntrinsic.ts
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export interface HeatmapIntrinsicRenderProps {
+ cells: number[];
+ cols: number;
+ cellSize: number;
+ cellGap: number;
+ cellRadius: number;
+ lowColor: JSX.Fill;
+ highColor: JSX.Fill;
+ emptyColor: JSX.Fill;
+}
+
+export interface HeatmapIntrinsicProps extends Partial> {
+ cells: number[];
+ cols: number;
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ cells,
+ cols,
+ cellSize = 12,
+ cellGap = 2,
+ cellRadius = 2,
+ lowColor = "rgba(255,255,255,0.08)",
+ highColor = "#4ade80",
+ emptyColor = "rgba(255,255,255,0.04)",
+ width,
+ height,
+ margin = 4,
+ location = "left",
+ align = "left",
+}) => {
+ const rows = Math.ceil(cells.length / cols);
+ const computedWidth = width ?? cols * (cellSize + cellGap) - cellGap;
+ const computedHeight = height ?? rows * (cellSize + cellGap) - cellGap;
+ return {
+ dimension: { width: computedWidth, height: computedHeight, margin },
+ style: { location, direction: "row", align },
+ props: { cells, cols, cellSize, cellGap, cellRadius, lowColor, highColor, emptyColor },
+ children: undefined,
+ };
+};
+
+const parseHexChannels = (s: string) => {
+ const m = s.match(/[\da-f]{2}/gi);
+ if (!m) return [0, 0, 0];
+ return m.slice(0, 3).map((x) => Number.parseInt(x, 16));
+};
+
+const lerpColor = (a: string, b: string, t: number): string => {
+ const [ar, ag, ab] = parseHexChannels(a);
+ const [br, bg, bb] = parseHexChannels(b);
+ const r = Math.round(ar + (br - ar) * t);
+ const g = Math.round(ag + (bg - ag) * t);
+ const bl = Math.round(ab + (bb - ab) * t);
+ return `#${[r, g, bl].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
+};
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ { cells, cols, cellSize, cellGap, cellRadius, lowColor, highColor, emptyColor },
+ { x, y }
+) => {
+ const max = Math.max(1, ...cells);
+ ctx.save();
+
+ cells.forEach((value, index) => {
+ const col = index % cols;
+ const row = Math.floor(index / cols);
+ const cx = x + col * (cellSize + cellGap);
+ const cy = y + row * (cellSize + cellGap);
+
+ if (value === 0) {
+ ctx.fillStyle = emptyColor;
+ } else if (typeof lowColor === "string" && typeof highColor === "string") {
+ ctx.fillStyle = lerpColor(lowColor, highColor, value / max);
+ } else {
+ ctx.fillStyle = highColor;
+ }
+
+ const r = cellRadius;
+ ctx.beginPath();
+ ctx.moveTo(cx + r, cy);
+ ctx.lineTo(cx + cellSize - r, cy);
+ ctx.arcTo(cx + cellSize, cy, cx + cellSize, cy + r, r);
+ ctx.lineTo(cx + cellSize, cy + cellSize - r);
+ ctx.arcTo(cx + cellSize, cy + cellSize, cx + cellSize - r, cy + cellSize, r);
+ ctx.lineTo(cx + r, cy + cellSize);
+ ctx.arcTo(cx, cy + cellSize, cx, cy + cellSize - r, r);
+ ctx.lineTo(cx, cy + r);
+ ctx.arcTo(cx, cy, cx + r, cy, r);
+ ctx.closePath();
+ ctx.fill();
+ });
+
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/Path.ts b/packages/rendering/src/intrinsics/Path.ts
new file mode 100644
index 000000000..e2da9e46c
--- /dev/null
+++ b/packages/rendering/src/intrinsics/Path.ts
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export type PathDrawFn = (ctx: CanvasRenderingContext2D, location: JSX.Location) => void;
+
+export interface PathRenderProps {
+ draw: PathDrawFn;
+}
+
+export interface PathProps {
+ draw: PathDrawFn;
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ draw,
+ width = "100%",
+ height = "100%",
+ margin = 0,
+ location = "left",
+ align = "left",
+}) => ({
+ dimension: { width, height, margin },
+ style: { location, direction: "row", align },
+ props: { draw },
+ children: undefined,
+});
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ { draw },
+ location
+) => {
+ ctx.save();
+ draw(ctx, location);
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/Radar.ts b/packages/rendering/src/intrinsics/Radar.ts
new file mode 100644
index 000000000..eea9ab5b4
--- /dev/null
+++ b/packages/rendering/src/intrinsics/Radar.ts
@@ -0,0 +1,140 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export interface RadarSeries {
+ values: number[];
+ color: JSX.Fill;
+ fillColor?: JSX.Fill;
+}
+
+export interface RadarRenderProps {
+ labels: string[];
+ series: [RadarSeries] | [RadarSeries, RadarSeries];
+ gridColor: JSX.Fill;
+ gridLevels: number;
+ lineWidth: number;
+ labelColor: JSX.Fill;
+ labelSize: number;
+ padding: number;
+}
+
+export interface RadarProps extends Partial> {
+ labels: string[];
+ series: [RadarSeries] | [RadarSeries, RadarSeries];
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ labels,
+ series,
+ gridColor = "rgba(255, 255, 255, 0.15)",
+ gridLevels = 4,
+ lineWidth = 1.5,
+ labelColor = "rgba(255, 255, 255, 0.7)",
+ labelSize = 11,
+ padding = 24,
+ width = 160,
+ height = 160,
+ margin = 4,
+ location = "center",
+ align = "center",
+}) => ({
+ dimension: { width, height, margin },
+ style: { location, direction: "row", align },
+ props: { labels, series, gridColor, gridLevels, lineWidth, labelColor, labelSize, padding },
+ children: undefined,
+});
+
+const angleFor = (i: number, n: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ { labels, series, gridColor, gridLevels, lineWidth, labelColor, labelSize, padding },
+ { x, y, width, height }
+) => {
+ const n = labels.length;
+ if (n < 3) return;
+
+ const cx = x + width / 2;
+ const cy = y + height / 2;
+ const radius = Math.max(1, Math.min(width, height) / 2 - padding);
+
+ ctx.save();
+ ctx.lineWidth = lineWidth;
+
+ for (let level = 1; level <= gridLevels; level++) {
+ const r = (radius * level) / gridLevels;
+ ctx.beginPath();
+ for (let i = 0; i < n; i++) {
+ const a = angleFor(i, n);
+ const px = cx + r * Math.cos(a);
+ const py = cy + r * Math.sin(a);
+ if (i === 0) {
+ ctx.moveTo(px, py);
+ } else {
+ ctx.lineTo(px, py);
+ }
+ }
+ ctx.closePath();
+ ctx.strokeStyle = gridColor;
+ ctx.stroke();
+ }
+
+ for (let i = 0; i < n; i++) {
+ const a = angleFor(i, n);
+ ctx.beginPath();
+ ctx.moveTo(cx, cy);
+ ctx.lineTo(cx + radius * Math.cos(a), cy + radius * Math.sin(a));
+ ctx.strokeStyle = gridColor;
+ ctx.stroke();
+ }
+
+ for (const s of series) {
+ const pts = s.values.map((v, i) => {
+ const a = angleFor(i, n);
+ const r = radius * Math.max(0, Math.min(1, v));
+ return { px: cx + r * Math.cos(a), py: cy + r * Math.sin(a) };
+ });
+
+ if (s.fillColor) {
+ ctx.beginPath();
+ pts.forEach(({ px, py }, i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));
+ ctx.closePath();
+ ctx.fillStyle = s.fillColor;
+ ctx.fill();
+ }
+
+ ctx.beginPath();
+ pts.forEach(({ px, py }, i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));
+ ctx.closePath();
+ ctx.strokeStyle = s.color;
+ ctx.lineWidth = lineWidth;
+ ctx.stroke();
+ }
+
+ ctx.fillStyle = labelColor;
+ ctx.font = `${labelSize}px sans-serif`;
+ ctx.textBaseline = "middle";
+
+ for (let i = 0; i < n; i++) {
+ const a = angleFor(i, n);
+ const lx = cx + (radius + labelSize) * Math.cos(a);
+ const ly = cy + (radius + labelSize) * Math.sin(a);
+ ctx.textAlign = Math.cos(a) > 0.1 ? "left" : (Math.cos(a) < -0.1 ? "right" : "center");
+ ctx.fillText(labels[i], lx, ly);
+ }
+
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/SparkBar.ts b/packages/rendering/src/intrinsics/SparkBar.ts
new file mode 100644
index 000000000..49522b16b
--- /dev/null
+++ b/packages/rendering/src/intrinsics/SparkBar.ts
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) Statsify
+ *
+ * This source code is licensed under the GNU GPL v3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ * https://github.com/Statsify/statsify/blob/main/LICENSE
+ */
+
+import { type CanvasRenderingContext2D } from "skia-canvas";
+import type * as JSX from "#jsx";
+
+export interface SparkBarRenderProps {
+ data: number[];
+ min?: number;
+ max?: number;
+ color: JSX.Fill;
+ highlightColor: JSX.Fill;
+ gap: number;
+ radius: number;
+ highlightLast: boolean;
+ padding: number;
+}
+
+export interface SparkBarProps extends Partial> {
+ data: number[];
+ width?: JSX.Measurement;
+ height?: JSX.Measurement;
+ margin?: JSX.Spacing;
+ location?: JSX.StyleLocation;
+ align?: JSX.StyleLocation;
+}
+
+export const component: JSX.RawFC = ({
+ data,
+ min,
+ max,
+ color = "#9ca3af",
+ highlightColor = "#4ade80",
+ gap = 2,
+ radius = 2,
+ highlightLast = false,
+ padding = 4,
+ width,
+ height = 40,
+ margin = 4,
+ location = "center",
+ align = "left",
+}) => ({
+ dimension: { width: width ?? data.length * 8 + (data.length - 1) * gap, height, margin },
+ style: { location, direction: "row", align },
+ props: { data, min, max, color, highlightColor, gap, radius, highlightLast, padding },
+ children: undefined,
+});
+
+const resolveNumber = (v = 0) => (Number.isFinite(v) ? v : 0);
+
+export const render: JSX.Render = (
+ ctx: CanvasRenderingContext2D,
+ { data, min, max, color, highlightColor, gap, radius, highlightLast, padding },
+ { x, y, width, height }
+) => {
+ const values = data.map(resolveNumber);
+ if (!values.length) return;
+
+ const computedMin = min ?? 0;
+ const computedMax = max ?? Math.max(1, ...values);
+ const range = computedMax - computedMin || 1;
+
+ const innerH = Math.max(1, height - padding * 2);
+ const barW = Math.max(1, (width - (values.length - 1) * gap) / values.length);
+ const lastIndex = values.length - 1;
+
+ ctx.save();
+
+ values.forEach((value, index) => {
+ const fraction = Math.max(0, Math.min(1, (value - computedMin) / range));
+ const barH = Math.max(radius * 2, innerH * fraction);
+ const bx = x + index * (barW + gap);
+ const by = y + padding + innerH - barH;
+
+ ctx.fillStyle = highlightLast && index === lastIndex ? highlightColor : color;
+
+ const r = Math.min(radius, barW / 2, barH / 2);
+ ctx.beginPath();
+ ctx.moveTo(bx + r, by);
+ ctx.lineTo(bx + barW - r, by);
+ ctx.arcTo(bx + barW, by, bx + barW, by + r, r);
+ ctx.lineTo(bx + barW, by + barH - r);
+ ctx.arcTo(bx + barW, by + barH, bx + barW - r, by + barH, r);
+ ctx.lineTo(bx + r, by + barH);
+ ctx.arcTo(bx, by + barH, bx, by + barH - r, r);
+ ctx.lineTo(bx, by + r);
+ ctx.arcTo(bx, by, bx + r, by, r);
+ ctx.closePath();
+ ctx.fill();
+ });
+
+ ctx.restore();
+};
diff --git a/packages/rendering/src/intrinsics/index.ts b/packages/rendering/src/intrinsics/index.ts
index 79a4928c0..7d05505de 100644
--- a/packages/rendering/src/intrinsics/index.ts
+++ b/packages/rendering/src/intrinsics/index.ts
@@ -6,7 +6,15 @@
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/
+export * as Arc from "./Arc.js";
export * as Box from "./Box.js";
export * as Div from "./Div.js";
+export * as Donut from "./Donut.js";
+export * as Gradient from "./Gradient.js";
+export * as Graph from "./Graph.js";
+export * as Heatmap from "./HeatmapIntrinsic.js";
export * as Image from "./Image.js";
+export * as Path from "./Path.js";
+export * as Radar from "./Radar.js";
+export * as SparkBar from "./SparkBar.js";
export * as Text from "./Text.js";
diff --git a/packages/rendering/src/jsx/instrinsics.ts b/packages/rendering/src/jsx/instrinsics.ts
index 2958ba374..a49bf45cd 100644
--- a/packages/rendering/src/jsx/instrinsics.ts
+++ b/packages/rendering/src/jsx/instrinsics.ts
@@ -6,13 +6,21 @@
* https://github.com/Statsify/statsify/blob/main/LICENSE
*/
-import { Box, Div, Image, Text } from "#intrinsics";
+import { Arc, Box, Div, Donut, Gradient, Graph, Heatmap, Image, Path, Radar, SparkBar, Text } from "#intrinsics";
import type { PropsWithChildren, RawFC, Render } from "./types.js";
export const intrinsicElements = {
- div: Div.component,
+ arc: Arc.component,
box: Box.component,
+ div: Div.component,
+ donut: Donut.component,
+ gradient: Gradient.component,
+ graph: Graph.component,
+ heatmap: Heatmap.component,
img: Image.component,
+ path: Path.component,
+ radar: Radar.component,
+ sparkbar: SparkBar.component,
text: Text.component,
};
@@ -33,8 +41,16 @@ export type IntrinsicRenders = {
};
export const intrinsicRenders: IntrinsicRenders = {
- div: Div.render,
+ arc: Arc.render,
box: Box.render,
+ div: Div.render,
+ donut: Donut.render,
+ gradient: Gradient.render,
+ graph: Graph.render,
+ heatmap: Heatmap.render,
img: Image.render,
+ path: Path.render,
+ radar: Radar.render,
+ sparkbar: SparkBar.render,
text: Text.render,
};