Build re-usable playerCard component with the skill triangle

This commit is contained in:
2025-10-29 21:21:50 -07:00
parent 5daf221cd7
commit f575844479
10 changed files with 506 additions and 142 deletions
+38 -6
View File
@@ -3,9 +3,11 @@
formatToolRequirementSummary,
meetsToolRequirement,
DEFAULT_ADMIN_RANK_FALLBACK,
PLEB_BEATLEADER_ID,
type BeatLeaderPlayerProfile,
type ToolRequirement
} from '$lib/utils/plebsaber-utils';
import PlayerCard from '$lib/components/PlayerCard.svelte';
const BL_PATREON_URL = 'https://www.patreon.com/BeatLeader';
@@ -14,17 +16,19 @@
export let customLockedMessage: string | null = null;
export let showCurrentRank = true;
export let adminRank: number | null = null;
export let adminPlayer: BeatLeaderPlayerProfile | null = null;
$: requirementContext = { adminRank };
$: hasAccess = meetsToolRequirement(player, requirement, requirementContext);
$: summary = formatToolRequirementSummary(requirement, requirementContext);
$: fallbackBaseline = DEFAULT_ADMIN_RANK_FALLBACK;
$: resolvedBaseline = typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0
? adminRank
: fallbackBaseline;
$: resolvedBaseline =
typeof adminRank === 'number' && Number.isFinite(adminRank) && adminRank > 0 ? adminRank : fallbackBaseline;
$: plebProfile = adminPlayer;
$: plebName = plebProfile?.name;
$: baselineCopy = resolvedBaseline
? `players ranked better than pleb (#${resolvedBaseline.toLocaleString()})`
: 'players ranked better than pleb';
? `players ranked better than ${plebName} (#${resolvedBaseline.toLocaleString()})`
: `players ranked better than ${plebName}`;
$: defaultLockedMessage = requirement?.requiresBetterRankThanAdmin
? `You must be a BL Patreon supporter or ${baselineCopy} to use this tool.`
: requirement?.lockedMessage ?? null;
@@ -47,15 +51,43 @@
{#if showLockedMessage}
<p class="mt-1">{showLockedMessage}</p>
{/if}
{#if plebProfile}
<div class="pleb-card">
<PlayerCard
name={plebProfile.name ?? plebName}
country={plebProfile.country ?? null}
rank={plebProfile.rank ?? resolvedBaseline}
showRank={Boolean(plebProfile.rank ?? resolvedBaseline)}
avatar={plebProfile.avatar ?? null}
avatarSize={48}
techPp={plebProfile?.techPp}
accPp={plebProfile?.accPp}
passPp={plebProfile?.passPp}
gradientId="pleb-baseline"
/>
</div>
{/if}
{#if showCurrentRank}
<p class="mt-2 text-xs text-amber-200/80">
{#if playerRankDisplay}
Current global rank: {playerRankDisplay}
{:else}
We couldn't determine your current BeatLeader rank. Refresh after your profile updates.
We couldn't determine your current BeatLeader rank. Try logging in.
{/if}
</p>
{/if}
</div>
{/if}
<style>
.pleb-card {
margin-top: 1rem;
display: flex;
}
.pleb-card :global(.summary-header) {
width: 100% !important;
max-width: 100% !important;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { calculateSkillTriangle } from '$lib/utils/plebsaber-utils';
export let name: string = 'Unknown';
export let country: string | null = null;
export let avatar: string | null = null;
export let rank: number | null = null;
export let showRank: boolean = true;
export let width: string = '12em';
export let avatarSize: number = 48;
export let techPp: number | null | undefined = null;
export let accPp: number | null | undefined = null;
export let passPp: number | null | undefined = null;
// gradientId must be unique per instance to avoid SVG gradient conflicts when multiple cards render on the same page
export let gradientId: string = 'summary-triangle';
$: triangle = calculateSkillTriangle(techPp, accPp, passPp);
$: triangleCorners = triangle?.corners;
$: normalized = triangle?.normalized;
$: hasTriangle = triangle !== null;
const gradientTechId = `${gradientId}-tech`;
const gradientAccId = `${gradientId}-acc`;
const gradientPassId = `${gradientId}-pass`;
</script>
<div class="summary-header" style={`--header-width:${width}; --avatar-size:${avatarSize}px`}>
<img src={avatar ?? ''} alt="Avatar" class:placeholder={!avatar} />
<div class="summary-body">
<div class="summary-name">{name}</div>
<div class="summary-meta">
{#if country}
<span>{country}</span>
{/if}
{#if showRank && typeof rank === 'number'}
<span>Rank: {rank}</span>
{/if}
</div>
</div>
{#if hasTriangle}
<div class="summary-triangle">
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" width="100%" height="100%" viewBox="0 0 100 86.6">
<g transform="matrix(1 0 0 -1 0 86.6)">
<defs>
<linearGradient id={gradientTechId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.tech.x} y1={triangleCorners!.tech.y} x2={(triangleCorners!.acc.x + triangleCorners!.pass.x) / 2} y2={(triangleCorners!.acc.y + triangleCorners!.pass.y) / 2}>
<stop offset="0%" stop-color={`rgb(255 0 120 / ${(normalized!.tech ?? 0) * 100}%)`} />
<stop offset="100%" stop-color={`rgb(255 0 120 / ${(normalized!.tech ?? 0) * 25}%)`} />
</linearGradient>
<linearGradient id={gradientAccId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.acc.x} y1={triangleCorners!.acc.y} x2={(triangleCorners!.pass.x + triangleCorners!.tech.x) / 2} y2={(triangleCorners!.pass.y + triangleCorners!.tech.y) / 2}>
<stop offset="0%" stop-color={`rgb(0 180 255 / ${(normalized!.acc ?? 0) * 100}%)`} />
<stop offset="100%" stop-color={`rgb(0 180 255 / ${(normalized!.acc ?? 0) * 25}%)`} />
</linearGradient>
<linearGradient id={gradientPassId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.pass.x} y1={triangleCorners!.pass.y} x2={(triangleCorners!.tech.x + triangleCorners!.acc.x) / 2} y2={(triangleCorners!.tech.y + triangleCorners!.acc.y) / 2}>
<stop offset="0%" stop-color={`rgb(0 255 180 / ${(normalized!.pass ?? 0) * 100}%)`} />
<stop offset="100%" stop-color={`rgb(0 255 180 / ${(normalized!.pass ?? 0) * 25}%)`} />
</linearGradient>
</defs>
<g stroke="#fff" stroke-width="0.5">
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientTechId})`} />
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientPassId})`} />
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientAccId})`} />
</g>
<g stroke="#fff" fill="none" stroke-width="2" stroke-dasharray="6 6">
<path d="M 50,0 L 0,86.6 100,86.6 Z" />
</g>
</g>
</svg>
</div>
{/if}
</div>
<style>
.summary-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: var(--header-width, 50%);
max-width: var(--header-width, 50%);
min-width: 200px;
flex: 0 0 var(--header-width, 50%);
}
img {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(34, 211, 238, 0.35);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.25);
}
img.placeholder {
opacity: 0.3;
border-style: dashed;
}
.summary-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.summary-name {
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
line-height: 1.1;
}
.summary-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.75);
}
.summary-triangle {
width: var(--avatar-size);
height: var(--avatar-size);
margin-left: auto;
flex-shrink: 0;
}
.summary-triangle svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 4px 12px rgba(34, 211, 238, 0.25));
}
</style>
@@ -0,0 +1,135 @@
<script lang="ts">
export let name: string = 'Unknown';
export let country: string | null = null;
export let avatar: string | null = null;
export let rank: number | null = null;
export let showRank: boolean = true;
export let width: string = '12em';
export let avatarSize: number = 48;
export let triangleCorners:
| {
tech: { x: number; y: number };
acc: { x: number; y: number };
pass: { x: number; y: number };
}
| null = null;
export let normalized:
| {
tech: number;
acc: number;
pass: number;
}
| null = null;
export let gradientId: string = 'summary-triangle';
const hasTriangle = triangleCorners && normalized;
const gradientTechId = `${gradientId}-tech`;
const gradientAccId = `${gradientId}-acc`;
const gradientPassId = `${gradientId}-pass`;
</script>
<div class="summary-header" style={`--header-width:${width}; --avatar-size:${avatarSize}px`}>
<img src={avatar ?? ''} alt="Avatar" class:placeholder={!avatar} />
<div class="summary-body">
<div class="summary-name">{name}</div>
<div class="summary-meta">
{#if country}
<span>{country}</span>
{/if}
{#if showRank && typeof rank === 'number'}
<span>Rank: {rank}</span>
{/if}
</div>
</div>
{#if hasTriangle}
<div class="summary-triangle">
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" width="100%" height="100%" viewBox="0 0 100 86.6">
<g transform="matrix(1 0 0 -1 0 86.6)">
<defs>
<linearGradient id={gradientTechId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.tech.x} y1={triangleCorners!.tech.y} x2={(triangleCorners!.acc.x + triangleCorners!.pass.x) / 2} y2={(triangleCorners!.acc.y + triangleCorners!.pass.y) / 2}>
<stop offset="0%" stop-color={`rgb(255 0 120 / ${(normalized!.tech ?? 0) * 100}%)`} />
<stop offset="100%" stop-color={`rgb(255 0 120 / ${(normalized!.tech ?? 0) * 25}%)`} />
</linearGradient>
<linearGradient id={gradientAccId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.acc.x} y1={triangleCorners!.acc.y} x2={(triangleCorners!.pass.x + triangleCorners!.tech.x) / 2} y2={(triangleCorners!.pass.y + triangleCorners!.tech.y) / 2}>
<stop offset="0%" stop-color={`rgb(0 180 255 / ${(normalized!.acc ?? 0) * 100}%)`} />
<stop offset="100%" stop-color={`rgb(0 180 255 / ${(normalized!.acc ?? 0) * 25}%)`} />
</linearGradient>
<linearGradient id={gradientPassId} gradientUnits="userSpaceOnUse" x1={triangleCorners!.pass.x} y1={triangleCorners!.pass.y} x2={(triangleCorners!.tech.x + triangleCorners!.acc.x) / 2} y2={(triangleCorners!.tech.y + triangleCorners!.acc.y) / 2}>
<stop offset="0%" stop-color={`rgb(0 255 180 / ${(normalized!.pass ?? 0) * 100}%)`} />
<stop offset="100%" stop-color={`rgb(0 255 180 / ${(normalized!.pass ?? 0) * 25}%)`} />
</linearGradient>
</defs>
<g stroke="#fff" stroke-width="0.5">
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientTechId})`} />
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientPassId})`} />
<path d={`M ${triangleCorners!.pass.x},${triangleCorners!.pass.y} L ${triangleCorners!.tech.x},${triangleCorners!.tech.y} ${triangleCorners!.acc.x},${triangleCorners!.acc.y} Z`} fill={`url(#${gradientAccId})`} />
</g>
<g stroke="#fff" fill="none" stroke-width="2" stroke-dasharray="6 6">
<path d="M 50,0 L 0,86.6 100,86.6 Z" />
</g>
</g>
</svg>
</div>
{/if}
</div>
<style>
.summary-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: var(--header-width, 50%);
max-width: var(--header-width, 50%);
min-width: 200px;
}
img {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(34, 211, 238, 0.35);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.25);
}
img.placeholder {
opacity: 0.3;
border-style: dashed;
}
.summary-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.summary-name {
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
line-height: 1.1;
}
.summary-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.75);
}
.summary-triangle {
width: var(--avatar-size);
height: var(--avatar-size);
margin-left: auto;
flex-shrink: 0;
}
.summary-triangle svg {
width: 100%;
height: 100%;
filter: drop-shadow(0 4px 12px rgba(34, 211, 238, 0.25));
}
</style>
+97 -3
View File
@@ -46,6 +46,9 @@ export type BeatLeaderPlayerProfile = {
country?: string | null;
rank?: number | null;
countryRank?: number | null;
techPp?: number | null;
accPp?: number | null;
passPp?: number | null;
};
export type RequirementContext = {
@@ -64,6 +67,23 @@ export type Difficulty = {
characteristic: string;
};
export type TriangleCorners = {
tech: { x: number; y: number };
acc: { x: number; y: number };
pass: { x: number; y: number };
};
export type TriangleNormalized = {
tech: number;
acc: number;
pass: number;
};
export type TriangleData = {
corners: TriangleCorners;
normalized: TriangleNormalized;
} | null;
// ============================================================================
// 2. Constants
// ============================================================================
@@ -413,7 +433,81 @@ export function percentile(values: number[], p: number): number {
}
// ============================================================================
// 6. Playlist Generation
// 6. Skill Triangle Calculations
// ============================================================================
const DEFAULT_MAX_TECH_PP = 1300;
const DEFAULT_MAX_ACC_PP = 15000;
const DEFAULT_MAX_PASS_PP = 6000;
const GYRON_LENGTH = 57.74;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
/**
* Calculate skill triangle data from player's PP values
* Returns corner coordinates and normalized values for rendering the skill triangle
*/
export function calculateSkillTriangle(
techPp: number | null | undefined,
accPp: number | null | undefined,
passPp: number | null | undefined
): TriangleData {
const tech = typeof techPp === 'number' ? techPp : 0;
const acc = typeof accPp === 'number' ? accPp : 0;
const pass = typeof passPp === 'number' ? passPp : 0;
// If all values are zero, return null
if (tech === 0 && acc === 0 && pass === 0) {
return null;
}
// Calculate triangle scale
const triangleScale = Math.max(
1,
tech > 0 ? tech / DEFAULT_MAX_TECH_PP : 0,
acc > 0 ? acc / DEFAULT_MAX_ACC_PP : 0,
pass > 0 ? pass / DEFAULT_MAX_PASS_PP : 0
);
const maxTechPp = DEFAULT_MAX_TECH_PP * triangleScale;
const maxAccPp = DEFAULT_MAX_ACC_PP * triangleScale;
const maxPassPp = DEFAULT_MAX_PASS_PP * triangleScale;
// Normalize PP values
const normalizedTechPp = maxTechPp ? clamp(tech / maxTechPp, 0, 1) : 0;
const normalizedAccPp = maxAccPp ? clamp(acc / maxAccPp, 0, 1) : 0;
const normalizedPassPp = maxPassPp ? clamp(pass / maxPassPp, 0, 1) : 0;
// Calculate corner positions
const cornerTech = {
x: (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) * 0.866,
y: 86.6 - (GYRON_LENGTH - normalizedTechPp * GYRON_LENGTH) / 2
};
const cornerAcc = {
x: 100 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) * 0.866,
y: 86.6 - (GYRON_LENGTH - normalizedAccPp * GYRON_LENGTH) / 2
};
const cornerPass = {
x: 50,
y: (86.6 - GYRON_LENGTH / 2) * (1 - normalizedPassPp)
};
return {
corners: {
tech: cornerTech,
acc: cornerAcc,
pass: cornerPass
},
normalized: {
tech: normalizedTechPp,
acc: normalizedAccPp,
pass: normalizedPassPp
}
};
}
// ============================================================================
// 7. Playlist Generation
// ============================================================================
/**
@@ -472,7 +566,7 @@ export function downloadPlaylist(playlistData: unknown): void {
}
// ============================================================================
// 7. Pagination Utilities
// 8. Pagination Utilities
// ============================================================================
export type PaginationResult<T> = {
@@ -503,7 +597,7 @@ export function calculatePagination<T>(
}
// ============================================================================
// 8. URL Parameter Utilities
// 9. URL Parameter Utilities
// ============================================================================
/**