Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,122 @@
.plusTier {
font-size: var(--font-xs);
font-weight: var(--weight-extra);
color: var(--graphic-accent);
}
.buildTitle {
overflow: hidden;
font-size: var(--font-lg);
font-weight: var(--weight-extra);
line-height: 1.2;
text-align: center;
text-overflow: ellipsis;
}
.weaponsRow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-2);
}
.weapon {
position: relative;
padding: var(--s-0-5);
border-radius: 50%;
background-color: var(--graphic-row-bg);
}
.top500 {
position: absolute;
top: -16px;
right: 14px;
z-index: 1;
}
.weaponName {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
}
.gearGrid {
display: grid;
grid-template-columns: repeat(3, max-content);
gap: var(--s-2) var(--s-3);
place-items: center;
justify-content: center;
padding-block-end: var(--s-3);
}
.noGear {
grid-template-columns: repeat(2, max-content);
}
.noAbilityPoints {
padding-block-end: 0;
}
.gear {
background-color: var(--graphic-row-bg);
border-radius: 50%;
}
.subAbilities {
display: flex;
align-items: flex-start;
gap: var(--s-3);
}
.subAbilitySegment {
position: relative;
}
.subAbilitySegmentIcons {
display: flex;
gap: var(--s-3);
}
.abilityPointsLabel {
position: absolute;
top: 100%;
inset-inline: 0;
margin-block-start: 5px;
border-top: 1px solid
color-mix(in oklch, var(--graphic-text-dim) 40%, transparent);
font-size: 0.7rem;
font-weight: var(--weight-semi);
line-height: 1.8;
text-align: center;
color: color-mix(in oklch, var(--graphic-text-dim) 70%, transparent);
}
.abilityChunks {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-3);
}
.abilityChunk {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.abilityChunkCount {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.description {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
line-height: 1.4;
text-align: center;
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,249 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Ability } from "~/components/Ability";
import { Image, ModeImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import type { Tables } from "~/db/tables";
import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types";
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
import { getAbilityChunksMapAsArray } from "~/features/build-analyzer/core/abilityChunksCalc";
import { apFromMap } from "~/features/build-analyzer/core/utils";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import type {
Ability as AbilityType,
BuildAbilitiesTuple,
GearType,
ModeShort,
} from "~/modules/in-game-lists/types";
import { gearImageUrl, navIconUrl, resolveAvatarUrl } from "~/utils/urls";
import styles from "./BuildGraphic.module.css";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicHeader,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
const BUILD_GRAPHIC_WIDTH = 380;
export interface BuildGraphicOwner {
username: string;
discordId: string;
plusTier: number | null;
customUrl?: Tables["User"]["customUrl"];
discordAvatar?: Tables["User"]["discordAvatar"];
customAvatarUrl?: string | null;
}
export function BuildGraphic({
build,
owner,
showTitle = true,
showAbilityPoints = true,
showAbilityChunks = false,
}: {
build: Pick<
Tables["Build"],
| "title"
| "description"
| "clothesGearSplId"
| "headGearSplId"
| "shoesGearSplId"
| "updatedAt"
> & {
abilities: BuildAbilitiesTuple;
modes: ModeShort[] | null;
weapons: Array<BuildWeaponWithTop500Info>;
};
owner: BuildGraphicOwner;
showTitle?: boolean;
showAbilityPoints?: boolean;
showAbilityChunks?: boolean;
}) {
const { t } = useTranslation(["weapons"]);
const abilityPoints = buildToAbilityPoints(build.abilities);
const isNoGear = [
build.headGearSplId,
build.clothesGearSplId,
build.shoesGearSplId,
].some((id) => typeof id !== "number");
return (
<GraphicContainer width={BUILD_GRAPHIC_WIDTH}>
<GraphicHeader
avatarUrl={resolveAvatarUrl({
customAvatarUrl: owner.customAvatarUrl,
discordId: owner.discordId,
discordAvatar: owner.discordAvatar,
size: "lg",
})}
identiconInput={owner.discordId}
titleRow={
<span className={graphicStyles.headerTitle}>
{owner.username}
{owner.plusTier ? (
<span className={styles.plusTier}> +{owner.plusTier}</span>
) : null}
</span>
}
subtitle={
<LocaleTime
date={build.updatedAt}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
build.modes && build.modes.length > 0
? build.modes.map((mode) => (
<ModeImage key={mode} mode={mode} size={24} />
))
: undefined
}
alignTrailingWithTitle
/>
{showTitle ? (
<div className={styles.buildTitle}>{build.title}</div>
) : null}
<div className={styles.weaponsRow}>
{build.weapons.map((weapon) => (
<div key={weapon.weaponSplId} className={styles.weapon}>
{weapon.isTop500 ? (
<Image
className={styles.top500}
path={navIconUrl("xsearch")}
alt=""
height={26}
width={26}
/>
) : null}
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={50}
/>
</div>
))}
{build.weapons.length === 1 ? (
<div className={styles.weaponName}>
{t(`weapons:MAIN_${build.weapons[0].weaponSplId}`)}
</div>
) : null}
</div>
<div
className={clsx(styles.gearGrid, {
[styles.noGear]: isNoGear,
[styles.noAbilityPoints]: !showAbilityPoints,
})}
>
<GearRow
gearType="HEAD"
abilities={build.abilities[0]}
gearId={build.headGearSplId}
abilityPoints={showAbilityPoints ? abilityPoints : null}
/>
<GearRow
gearType="CLOTHES"
abilities={build.abilities[1]}
gearId={build.clothesGearSplId}
abilityPoints={showAbilityPoints ? abilityPoints : null}
/>
<GearRow
gearType="SHOES"
abilities={build.abilities[2]}
gearId={build.shoesGearSplId}
abilityPoints={showAbilityPoints ? abilityPoints : null}
/>
</div>
{showAbilityChunks ? (
<AbilityChunksRow abilities={build.abilities} />
) : null}
{build.description ? (
<div className={styles.description}>{build.description}</div>
) : null}
</GraphicContainer>
);
}
function GearRow({
gearType,
abilities,
gearId,
abilityPoints,
}: {
gearType: GearType;
abilities: AbilityType[];
gearId: number | null;
/** When null the ability point labels are hidden */
abilityPoints: AbilityPoints | null;
}) {
const { t } = useTranslation(["analyzer"]);
const [mainAbility, ...subAbilities] = abilities;
return (
<>
{typeof gearId === "number" ? (
<Image
height={80}
width={80}
alt=""
path={gearImageUrl(gearType, gearId)}
className={styles.gear}
/>
) : null}
<Ability ability={mainAbility} size="HUGE" />
<div className={styles.subAbilities}>
{subAbilitySegments(subAbilities).map((segment, segmentIndex) => (
<div key={segmentIndex} className={styles.subAbilitySegment}>
<div className={styles.subAbilitySegmentIcons}>
{Array.from({ length: segment.count }, (_, index) => (
<Ability key={index} ability={segment.ability} size="MAIN" />
))}
</div>
{abilityPoints ? (
<div className={styles.abilityPointsLabel}>
{apFromMap({ abilityPoints, ability: segment.ability })}
{t("analyzer:abilityPoints.short")}
</div>
) : null}
</div>
))}
</div>
</>
);
}
function AbilityChunksRow({ abilities }: { abilities: BuildAbilitiesTuple }) {
const abilityChunks = getAbilityChunksMapAsArray(abilities);
if (abilityChunks.length === 0) return null;
return (
<div className={styles.abilityChunks}>
{abilityChunks.map(([ability, count]) => (
<div key={ability} className={styles.abilityChunk}>
<Ability ability={ability} size="SUB" />
<div className={styles.abilityChunkCount}>{count}</div>
</div>
))}
</div>
);
}
function subAbilitySegments(subAbilities: AbilityType[]) {
const segments: Array<{ ability: AbilityType; count: number }> = [];
for (const ability of subAbilities) {
const previousSegment = segments.at(-1);
if (previousSegment?.ability === ability) {
previousSegment.count++;
} else {
segments.push({ ability, count: 1 });
}
}
return segments;
}

View File

@@ -0,0 +1,342 @@
.container {
--graphic-row-bg: var(--color-bg-high);
--graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent);
--graphic-text-dim: var(--color-text-high);
--graphic-accent: var(--color-text-accent);
--graphic-first: oklch(87% 0.13 85);
--graphic-second: oklch(83% 0.015 268);
--graphic-third: oklch(74% 0.09 55);
--graphic-win: var(--color-success-high);
--graphic-loss: var(--color-error-high);
display: flex;
flex-direction: column;
gap: var(--s-4);
width: 720px;
padding: var(--s-6);
background:
radial-gradient(
ellipse 90% 45% at 50% -5%,
color-mix(in oklch, var(--color-accent) 25%, transparent),
transparent 70%
),
var(--color-bg);
color: var(--color-text);
/* the exported image resolves fonts slightly differently, so any text allowed to wrap eventually does */
white-space: nowrap;
border: 1.5px solid var(--color-border-high);
border-radius: var(--radius-box);
}
/*
Zero-specificity theme scoping so that a forced [data-theme] wrapper always
beats the html class regardless of which one is in effect
*/
:global(:where(html.light)) .container,
:global(:where([data-theme="light"])) .container {
--graphic-first: oklch(58% 0.12 85);
--graphic-second: oklch(48% 0.02 268);
--graphic-third: oklch(50% 0.1 55);
}
:global(:where([data-theme="dark"])) .container {
--graphic-first: oklch(87% 0.13 85);
--graphic-second: oklch(83% 0.015 268);
--graphic-third: oklch(74% 0.09 55);
}
.header {
display: flex;
align-items: center;
gap: var(--s-3);
}
/*
The image export freezes each element's width, so a shrink-to-fit box would clip its own
text. Both sides of the header grow into the free space instead, which keeps them at the
same place as an auto margin would but leaves their text room to breathe.
*/
.headerText {
flex: 1 1 auto;
min-width: 0;
}
.headerTitleRow {
display: flex;
align-items: center;
gap: var(--s-2);
& > :not(.headerTitle) {
flex-shrink: 0;
}
}
.headerTitle {
/* the export freezes this box at its exact text width, so it has to claim the row's free
space rather than shrink-wrap, or a glyph the raster measures wider gets ellipsised away.
Basis stays auto so the title still contributes its text to the row's intrinsic width */
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: var(--font-lg);
font-weight: var(--weight-extra);
line-height: 1.15;
white-space: nowrap;
text-overflow: ellipsis;
}
.headerSubtitle {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.headerTrailing {
display: flex;
flex-grow: 1;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: var(--s-2);
}
.box {
padding: var(--s-2) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.boxLabel {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--graphic-text-dim);
}
.teamsList {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.teamRow {
display: grid;
grid-template-columns: 3rem 2.75rem 1fr auto;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
&.teamRowFirst {
background:
linear-gradient(
to right,
color-mix(in oklch, var(--graphic-first) 18%, transparent),
transparent 40%
),
var(--graphic-row-bg);
border-color: color-mix(in oklch, var(--graphic-first) 40%, transparent);
}
}
.placement {
font-size: var(--font-lg);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--graphic-text-dim);
& sup {
font-size: var(--font-2xs);
font-weight: var(--weight-extra);
}
}
.placementFirst {
color: var(--graphic-first);
}
.placementSecond {
color: var(--graphic-second);
}
.placementThird {
color: var(--graphic-third);
}
.teamInfo {
min-width: 0;
overflow: hidden;
}
.teamName {
overflow: hidden;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
white-space: nowrap;
text-overflow: ellipsis;
}
.avatarCell {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.teamSeed {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
white-space: nowrap;
color: var(--graphic-text-dim);
}
.playersList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--s-0-5) var(--s-3);
margin-top: var(--s-1);
}
.player {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--s-1-5);
min-width: 0;
overflow: hidden;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
/* no ellipsis: the chips wrap and a username is capped well below the row width, so truncating
here only ever fired on the frozen-width rounding described above */
.playerName {
white-space: nowrap;
}
.weapons {
display: flex;
gap: var(--s-1);
padding: var(--s-1-5);
background-color: oklch(0% 0 0 / 0.3);
border-radius: var(--radius-field);
}
.weaponKit {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.statsRow {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: var(--s-2);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
min-width: 0;
padding: var(--s-1-5) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.statValue {
/* full width rather than shrink-to-fit so the image export cannot clip the value */
width: 100%;
font-size: var(--font-lg);
text-align: center;
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
}
.statSeparator {
color: var(--graphic-text-dim);
}
.statWin {
color: var(--graphic-win);
}
.statLoss {
color: var(--graphic-loss);
}
.score {
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
text-align: center;
}
.scoreWin {
color: var(--graphic-win);
}
.scoreLoss {
color: var(--graphic-loss);
}
.sectionDivider {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
font-size: var(--font-xs);
font-weight: var(--weight-extra);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--graphic-accent);
&::before,
&::after {
content: "";
flex: 1;
height: 1px;
background-color: var(--graphic-row-border);
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-xs);
font-weight: var(--weight-extra);
color: var(--graphic-text-dim);
}
.footerAccent {
color: var(--graphic-accent);
}
.qrCodeRow {
display: flex;
justify-content: center;
}
.qrCode {
padding: var(--s-1-5);
background-color: #fff;
border-radius: var(--radius-field);
& svg {
display: block;
}
}

View File

@@ -0,0 +1,289 @@
import clsx from "clsx";
import { QRCodeSVG } from "qrcode.react";
import * as React from "react";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { SpecialWeaponImage, WeaponImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
import { weaponParams } from "~/features/build-analyzer/core/utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import styles from "./Graphic.module.css";
export const GRAPHIC_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
/**
* Sizes that keep the code at roughly two pixels per module. The lengths are the byte
* capacities of the QR versions used at the default "L" error correction level.
*/
const QR_CODE_SIZE_BREAKPOINTS = [
{ maxUrlLength: 106, size: 56 },
{ maxUrlLength: 271, size: 84 },
{ maxUrlLength: 523, size: 116 },
] as const;
const QR_CODE_SIZE_MAX = 144;
/** Full URL the graphic's QR code should link to, provided by `ImageExportDialog` (null = no QR code) */
export const GraphicQrCodeContext = React.createContext<string | null>(null);
export interface GraphicPlayer {
name: string;
countryCode?: string;
}
export interface GraphicTeam {
name: string;
logoUrl?: string;
seed?: number;
players: GraphicPlayer[];
weapons: MainWeaponId[];
}
export function GraphicContainer({
children,
width,
}: {
children: React.ReactNode;
width?: number;
}) {
const qrCodeUrl = React.useContext(GraphicQrCodeContext);
return (
<div className={styles.container} style={width ? { width } : undefined}>
{children}
{qrCodeUrl ? (
<div className={styles.qrCodeRow}>
<div className={styles.qrCode}>
<QRCodeSVG value={qrCodeUrl} size={qrCodeSize(qrCodeUrl)} />
</div>
</div>
) : null}
</div>
);
}
export function GraphicHeader({
avatarUrl,
identiconInput,
titleRow,
subtitle,
trailing,
alignTrailingWithTitle = false,
}: {
avatarUrl?: string;
identiconInput: string;
titleRow: React.ReactNode;
subtitle: React.ReactNode;
trailing?: React.ReactNode;
/** Line the trailing content up with the title instead of centering it against the title and subtitle together */
alignTrailingWithTitle?: boolean;
}) {
const trailingContent = trailing ? (
<div className={styles.headerTrailing}>{trailing}</div>
) : null;
return (
<header className={styles.header}>
<Avatar
url={avatarUrl}
identiconInput={identiconInput}
size="sm"
alt=""
/>
<div className={styles.headerText}>
<div className={styles.headerTitleRow}>
{titleRow}
{alignTrailingWithTitle ? trailingContent : null}
</div>
{subtitle}
</div>
{alignTrailingWithTitle ? null : trailingContent}
</header>
);
}
export function GraphicTeamsList({ children }: { children: React.ReactNode }) {
return <ol className={styles.teamsList}>{children}</ol>;
}
export function GraphicTeamRow({
team,
leading,
highlighted = false,
className,
as: Element = "li",
}: {
team: GraphicTeam;
leading?: React.ReactNode;
highlighted?: boolean;
className?: string;
as?: "li" | "div";
}) {
return (
<Element
className={clsx(styles.teamRow, className, {
[styles.teamRowFirst]: highlighted,
})}
>
{leading}
<div className={styles.avatarCell}>
<Avatar
url={team.logoUrl}
identiconInput={team.name}
size="sm"
alt=""
/>
{typeof team.seed === "number" ? (
<div className={styles.teamSeed}>#{team.seed}</div>
) : null}
</div>
<div className={styles.teamInfo}>
<div className={styles.teamName}>{team.name}</div>
<div className={styles.playersList}>
{team.players.map((player) => (
<GraphicPlayerChip key={player.name} player={player} />
))}
</div>
</div>
{team.weapons.length > 0 ? (
<div className={styles.weapons}>
{team.weapons.map((weaponSplId, index) => (
<div key={`${weaponSplId}-${index}`} className={styles.weaponKit}>
<WeaponImage
weaponSplId={weaponSplId}
variant="badge"
size={38}
/>
<SpecialWeaponImage
specialWeaponId={
weaponParams().weaponKits[weaponSplId].specialWeaponId
}
size={24}
/>
</div>
))}
</div>
) : null}
</Element>
);
}
/**
* A chip is sized by its content, and the image export freezes each element's width. Inline
* elements are exempt from that, so the chip must stay a `span` or its name gets clipped.
*/
export function GraphicPlayerChip({ player }: { player: GraphicPlayer }) {
return (
<span className={styles.player}>
{player.countryCode ? (
<Flag countryCode={player.countryCode} tiny />
) : null}
<span className={styles.playerName}>{player.name}</span>
</span>
);
}
export function GraphicPlacementCell({ placement }: { placement: number }) {
return (
<div className={clsx(styles.placement, placementAccentClass(placement))}>
<Placement placement={placement} textOnly />
</div>
);
}
export function GraphicStatsRow({ children }: { children: React.ReactNode }) {
return <div className={styles.statsRow}>{children}</div>;
}
export function GraphicStat({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className={styles.stat}>
<div className={styles.boxLabel}>{label}</div>
<div className={styles.statValue}>{children}</div>
</div>
);
}
export function GraphicWonLost({ won, lost }: { won: number; lost: number }) {
return (
<>
<span className={styles.statWin}>{won}</span>
<span className={styles.statSeparator}>-</span>
<span className={styles.statLoss}>{lost}</span>
</>
);
}
export function GraphicScore({
ownScore,
opponentScore,
}: {
ownScore: number;
opponentScore: number;
}) {
return (
<div
className={clsx(
styles.score,
ownScore > opponentScore ? styles.scoreWin : styles.scoreLoss,
)}
>
{ownScore}-{opponentScore}
</div>
);
}
export function GraphicSectionDivider({
children,
as: Element = "div",
}: {
children: React.ReactNode;
as?: "div" | "li";
}) {
return <Element className={styles.sectionDivider}>{children}</Element>;
}
export function GraphicFooter({ children }: { children: React.ReactNode }) {
return <footer className={styles.footer}>{children}</footer>;
}
export function GraphicSiteUrl({ path }: { path: string }) {
return (
<div>
sendou<span className={styles.footerAccent}>.ink</span>
{path}
</div>
);
}
function qrCodeSize(url: string) {
for (const breakpoint of QR_CODE_SIZE_BREAKPOINTS) {
if (url.length <= breakpoint.maxUrlLength) {
return breakpoint.size;
}
}
return QR_CODE_SIZE_MAX;
}
function placementAccentClass(placement: number) {
switch (placement) {
case 1:
return styles.placementFirst;
case 2:
return styles.placementSecond;
case 3:
return styles.placementThird;
default:
return undefined;
}
}

View File

@@ -0,0 +1,24 @@
/* doubled selector so it wins over the base modal max-width regardless of CSS module order */
.dialog.dialog {
max-width: fit-content;
}
.settings {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-3) var(--s-4);
}
.scroller {
max-width: 100%;
overflow-x: auto;
}
/* auto margins center the frame when the settings make the dialog wider than the graphic, but
never clip it when the graphic itself is the wider one (unlike flex centering) */
.frame {
width: fit-content;
margin-inline: auto;
}

View File

@@ -0,0 +1,268 @@
import { HardDriveDownload, Share2 } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useMatches } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import {
SendouChipRadio,
SendouChipRadioGroup,
} from "~/components/elements/ChipRadio";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouSwitch } from "~/components/elements/Switch";
import { useTheme } from "~/features/theme/core/provider";
import { SENDOU_INK_BASE_URL } from "~/utils/urls";
import { GraphicQrCodeContext } from "./Graphic";
import styles from "./ImageExportDialog.module.css";
const EXPORT_SCALE = 1.75;
const COARSE_POINTER_QUERY = "(pointer: coarse)";
type ThemeSelection = "light" | "dark" | "light-custom" | "dark-custom";
const THEME_SELECTIONS = [
{
value: "light",
translationKey: "common:imageExport.theme.light",
needsCustomTheme: false,
},
{
value: "dark",
translationKey: "common:imageExport.theme.dark",
needsCustomTheme: false,
},
{
value: "light-custom",
translationKey: "common:imageExport.theme.lightCustom",
needsCustomTheme: true,
},
{
value: "dark-custom",
translationKey: "common:imageExport.theme.darkCustom",
needsCustomTheme: true,
},
] as const;
interface ImageExportDialogProps {
/** Button that opens the dialog, e.g. a `SendouButton` (its own `onPress` also runs, useful for lazy loading the graphic's data) */
trigger: React.ReactNode;
heading: string;
/** Name of the downloaded file without the extension */
filename: string;
/** Path the QR code links to, defaults to the current page */
qrCodePath?: string;
/** Extra settings controls specific to the use case */
settings?: React.ReactNode;
/** The graphic to preview and export */
children: React.ReactNode;
}
/**
* Dialog for exporting a graphic component as a .png image. Renders the given graphic
* as a preview with generic settings (color scheme, custom theme, QR code) and downloads
* a screenshot of it via snapdom. Graphics render their QR code via {@link GraphicQrCodeContext}.
*/
export function ImageExportDialog({
trigger,
heading,
...contentProps
}: ImageExportDialogProps) {
return (
<SendouDialog
trigger={trigger}
heading={heading}
showCloseButton
className={styles.dialog}
>
<ImageExportDialogContent {...contentProps} />
</SendouDialog>
);
}
function ImageExportDialogContent({
filename,
qrCodePath,
settings,
children,
}: Omit<ImageExportDialogProps, "trigger" | "heading">) {
const { t } = useTranslation(["common"]);
const { htmlThemeClass } = useTheme();
const location = useLocation();
const pageHasCustomTheme = usePageHasCustomTheme();
const isMobile = useIsMobile();
const [themeSelection, setThemeSelection] = React.useState<ThemeSelection>(
() => {
const mode = htmlThemeClass === "light" ? "light" : "dark";
return pageHasCustomTheme ? `${mode}-custom` : mode;
},
);
const [withQrCode, setWithQrCode] = React.useState(true);
const [isDownloading, setIsDownloading] = React.useState(false);
const frameRef = React.useRef<HTMLDivElement>(null);
const theme = themeSelection.startsWith("light") ? "light" : "dark";
const useCustomTheme = themeSelection.endsWith("-custom");
const qrCodeUrl = `${SENDOU_INK_BASE_URL}${qrCodePath ?? `${location.pathname}${location.search}`}`;
// snapdom re-downloads every image at export time rather than reusing what the preview
// already painted, and silently drops any that fails. Warming them while the preview sits
// idle keeps those fetches from racing the capture's own work for the main thread.
// Runs after every render because settings can mount images that were not there before
// (e.g. the build export's ability chunks); re-running once everything is cached is ~7ms.
React.useEffect(() => {
if (isDownloading) return;
let cancelled = false;
import("@zumer/snapdom").then(({ preCache }) => {
if (cancelled || !frameRef.current) return;
preCache(frameRef.current).catch(() => {});
});
return () => {
cancelled = true;
};
});
const handleExport = async () => {
if (!frameRef.current) return;
setIsDownloading(true);
try {
const { snapdom } = await import("@zumer/snapdom");
// defensive/speculative fix
await document.fonts.ready;
const blob = await snapdom.toBlob(frameRef.current, {
type: "png",
quality: 1,
scale: EXPORT_SCALE,
// snapdom's own download helper forces this, without it the export size would vary by device
dpr: 1,
embedFonts: true,
// without this snapdom re-encodes images down to their rendered size, making e.g. the tier image look rough
compress: false,
// names ending in a glyph outside the graphic's font (emoji, Greek, ...) get measured with
// one fallback font in the page and another when rasterized, so a box frozen to its exact
// text width ends up an ellipsis short. This re-measures the clone and pins diverging boxes
reconcile: true,
});
await saveImage(blob, `${filename}.png`, { canShare: isMobile });
} finally {
setIsDownloading(false);
}
};
return (
<div className="stack md">
<div className={styles.settings}>
<SendouChipRadioGroup wrap>
{THEME_SELECTIONS.filter(
(selection) => pageHasCustomTheme || !selection.needsCustomTheme,
).map((selection) => (
<SendouChipRadio
key={selection.value}
name="image-export-theme"
value={selection.value}
checked={themeSelection === selection.value}
onChange={() => setThemeSelection(selection.value)}
>
{t(selection.translationKey)}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
<SendouSwitch isSelected={withQrCode} onChange={setWithQrCode}>
{t("common:imageExport.qrCode")}
</SendouSwitch>
{settings}
</div>
<SendouButton
icon={isMobile ? <Share2 /> : <HardDriveDownload />}
onPress={handleExport}
isDisabled={isDownloading}
className="mx-auto"
>
{isDownloading
? t("common:actions.loading")
: isMobile
? t("common:actions.share")
: t("common:imageExport.download")}
</SendouButton>
<div className={styles.scroller}>
<div
ref={frameRef}
className={styles.frame}
data-theme={theme}
data-default-theme={
pageHasCustomTheme && !useCustomTheme ? true : undefined
}
data-testid="image-export-frame"
>
<GraphicQrCodeContext.Provider value={withQrCode ? qrCodeUrl : null}>
{children}
</GraphicQrCodeContext.Provider>
</div>
</div>
</div>
);
}
/**
* Opens the share sheet when sharing is allowed (mobile) and the platform supports it,
* otherwise downloads the image
*/
async function saveImage(
blob: Blob,
filename: string,
{ canShare }: { canShare: boolean },
) {
const file = new File([blob], filename, { type: blob.type });
if (canShare && navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: filename });
return;
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
}
// a blob url is used over a data url because iOS Safari fails to save big data urls
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function subscribeToPointerQuery(callback: () => void) {
const mediaQueryList = window.matchMedia(COARSE_POINTER_QUERY);
mediaQueryList.addEventListener("change", callback);
return () => mediaQueryList.removeEventListener("change", callback);
}
function useIsMobile() {
return React.useSyncExternalStore(
subscribeToPointerQuery,
() => window.matchMedia(COARSE_POINTER_QUERY).matches,
() => false,
);
}
function usePageHasCustomTheme() {
const matches = useMatches();
return matches.some((match) =>
Boolean(
(match.loaderData as { customTheme?: unknown } | undefined)?.customTheme,
),
);
}

View File

@@ -0,0 +1,383 @@
.seasonBadge {
font-size: var(--font-md);
font-weight: var(--weight-extra);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--graphic-accent);
}
.hero {
display: flex;
align-items: center;
gap: var(--s-4);
background:
linear-gradient(
to right,
color-mix(in oklch, var(--graphic-accent) 16%, transparent),
transparent 40%
),
var(--graphic-row-bg);
border-color: color-mix(in oklch, var(--graphic-accent) 35%, transparent);
}
.heroTierName {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
line-height: 1.1;
}
.heroSp {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
font-variant-numeric: tabular-nums;
}
.heroPeak {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.rankBlock {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
margin-inline-start: auto;
padding-inline-end: var(--s-2);
}
.rankValue {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
color: var(--graphic-accent);
}
.bestStageRow {
--stage-banner-fade: var(--graphic-row-bg);
}
.bestStageName {
font-size: var(--font-md);
font-weight: var(--weight-extra);
}
.bestStageWinrate {
font-weight: var(--weight-extra);
color: var(--graphic-accent);
}
.chart {
display: block;
width: 100%;
/* the area gradient's stops resolve their color from here via currentColor */
color: var(--graphic-accent);
}
.chartLine {
fill: none;
stroke: var(--graphic-accent);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.chartAreaTop {
stop-color: var(--graphic-accent);
stop-opacity: 0.3;
}
.chartAreaBottom {
stop-color: var(--graphic-accent);
stop-opacity: 0;
}
.chartGridLine {
stroke: var(--graphic-row-border);
stroke-width: 1;
}
.chartLabel {
font-size: 10px;
font-weight: var(--weight-semi);
fill: var(--graphic-text-dim);
}
.chartPeakLabel {
font-size: 11px;
font-weight: var(--weight-extra);
fill: var(--color-text);
}
.chartDot {
fill: var(--graphic-accent);
stroke: var(--graphic-row-bg);
stroke-width: 2;
}
.middleGrid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: var(--s-2);
}
.sideStack {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.activityBox {
/* categorical palette, all-pairs CVD separation validated against the dark surface */
--activity-none: color-mix(in oklch, var(--color-text) 8%, transparent);
--activity-sq: oklch(70% 0.11 253);
--activity-tournament: oklch(75% 0.12 85);
--activity-both: oklch(94% 0.02 253);
}
:global(:where(html.light)) .activityBox,
:global(:where([data-theme="light"])) .activityBox {
--activity-sq: oklch(56% 0.13 253);
--activity-tournament: oklch(66% 0.13 85);
--activity-both: oklch(30% 0.04 253);
}
:global(:where([data-theme="dark"])) .activityBox {
--activity-sq: oklch(70% 0.11 253);
--activity-tournament: oklch(75% 0.12 85);
--activity-both: oklch(94% 0.02 253);
}
.calendar {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, auto);
justify-content: center;
gap: 4px;
margin-top: var(--s-4);
}
.calendarCell {
width: 18px;
height: 18px;
border-radius: 3px;
background-color: var(--activity-none);
&.calendarCellHidden {
visibility: hidden;
}
&.calendarSq {
background-color: var(--activity-sq);
}
&.calendarTournament {
background-color: var(--activity-tournament);
}
&.calendarBoth {
background-color: var(--activity-both);
}
}
.calendarLegend {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2-5);
margin-top: var(--s-3);
}
.calendarLegendItem {
display: flex;
align-items: center;
gap: var(--s-1-5);
& .calendarCell {
width: 10px;
height: 10px;
}
}
.weaponsRow {
display: flex;
justify-content: space-evenly;
gap: var(--s-3);
margin-top: var(--s-1-5);
}
.weaponUsage {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.matesList {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
margin-top: var(--s-1-5);
}
.matesBoxExpanded {
display: flex;
flex: 1;
flex-direction: column;
& .matesList {
flex: 1;
justify-content: space-evenly;
}
}
.mateRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.mateName {
display: flex;
/* the image export freezes each element's width, so a shrink-to-fit box would clip its own text */
flex: 1;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
}
.mateNameText {
/* claims .mateName's free space, see .headerTitle */
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mateSets {
margin-inline-start: auto;
white-space: nowrap;
}
.teamRankRow {
display: flex;
align-items: center;
gap: var(--s-3);
/* the info column takes the free space here, so an auto margin would leave it nothing to grow into */
& .rankBlock {
margin-inline-start: 0;
}
}
.teamRankInfo {
/* the image export freezes each element's width, so a shrink-to-fit box would clip its own text */
flex: 1;
min-width: 0;
}
.teamRankTitle {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.teamRankName {
overflow: hidden;
font-size: var(--font-md);
font-weight: var(--weight-extra);
white-space: nowrap;
text-overflow: ellipsis;
}
.teamRankSp {
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
&.teamRankSpSecondary {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
}
.playersInline {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--s-0-5) var(--s-3);
}
.bestSetsList {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin: 0;
padding: 0;
list-style: none;
}
.bestSetRow {
display: grid;
grid-template-columns: 3.25rem 1fr auto;
align-items: center;
gap: var(--s-3);
}
.playersInlineStart {
justify-content: flex-start;
margin-top: var(--s-1);
}
.setInfo {
min-width: 0;
}
.setContext {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.setSp {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
padding: 0 var(--s-2);
}
.setSpValue {
font-size: var(--font-md);
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
}
.tournamentRow {
display: grid;
grid-template-columns: 3.25rem 2.75rem 1fr auto;
align-items: center;
gap: var(--s-3);
}
.tournamentInfo {
min-width: 0;
}
.tournamentName {
overflow: hidden;
font-size: var(--font-sm);
font-weight: var(--weight-extra);
white-space: nowrap;
text-overflow: ellipsis;
}
.tournamentMeta {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}

View File

@@ -0,0 +1,604 @@
import clsx from "clsx";
import {
eachDayOfInterval,
format,
parseISO,
startOfDay,
startOfWeek,
} from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { TierImage, WeaponImage } from "~/components/Image";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { StageBannerBox } from "~/components/StageBannerBox";
import { TierPill } from "~/components/TierPill";
import type { TierName } from "~/features/mmr/mmr-constants";
import { userSeasonsPage } from "~/features/user-page/user-page-urls";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
type GraphicPlayer,
GraphicPlayerChip,
GraphicQrCodeContext,
GraphicScore,
GraphicSectionDivider,
GraphicSiteUrl,
GraphicStat,
GraphicStatsRow,
GraphicWonLost,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import styles from "./SeasonSummaryGraphic.module.css";
const CHART_WIDTH = 672;
const CHART_HEIGHT = 170;
const CHART_MARGIN = { top: 26, right: 14, bottom: 22, left: 14 };
const CHART_POINTS_NEEDED = 2;
const CHART_PEAK_LABEL_CLAMP = 48;
const TOP_MATES_COUNT = 3;
/** Without weapons the teammates box is alone next to the activity calendar, so it has room for more */
const TOP_MATES_COUNT_WITHOUT_WEAPONS = 6;
export type SeasonSummaryGraphicActivity = "sq" | "tournament" | "both";
export interface SeasonSummaryGraphicBestSet {
opponentPlayers: GraphicPlayer[];
ownScore: number;
opponentScore: number;
/** Average SP of the opposing players at the time the set was played */
opponentSp: number;
/** Where the set was played e.g. "SendouQ" or a tournament name */
context: string;
}
export interface SeasonSummaryGraphicStats {
tier: { name: TierName; isPlus: boolean };
sp: number;
setsWon: number;
setsLost: number;
mapsWon: number;
mapsLost: number;
longestWinStreak: number;
/** Sets that went to a deciding map: how many of those were won */
clutch?: { won: number; total: number };
soloRank?: number;
teamRank?: {
/** Omitted when the roster is not on the main team leaderboard */
rank?: number;
sp: number;
mates: GraphicPlayer[];
team?: {
name: string;
logoUrl?: string;
};
};
topMates: Array<{
player: GraphicPlayer;
discordId: string;
avatarUrl?: string;
setsCount: number;
}>;
bestStage?: { stageId: StageId; winratePercentage: number };
/** Per day peak SP, dates in "yyyy-MM-dd" format */
spProgression: Array<{ date: string; sp: number }>;
/** Days with at least one set played, dates in "yyyy-MM-dd" format */
activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>;
bestSets: SeasonSummaryGraphicBestSet[];
bestTournament?: {
name: string;
logoUrl?: string;
tier?: number;
placement: number;
teamsCount: number;
};
topWeapons: Array<{ weaponSplId: MainWeaponId; usagePercentage: number }>;
}
export function SeasonSummaryGraphic({
user,
season,
seasonDateRange,
stats,
}: {
user: {
name: string;
discordId: string;
customUrl?: string;
countryCode?: string;
avatarUrl?: string;
};
season: number;
seasonDateRange: { starts: Date; ends: Date };
stats: SeasonSummaryGraphicStats;
}) {
const { t } = useTranslation(["user", "calendar", "game-misc"]);
const qrCodeUrl = React.useContext(GraphicQrCodeContext);
const {
tier,
sp,
setsWon,
setsLost,
mapsWon,
mapsLost,
longestWinStreak,
clutch,
soloRank,
teamRank,
topMates,
bestStage,
spProgression,
activeDays,
bestSets,
bestTournament,
topWeapons,
} = stats;
const peakSp =
spProgression.length > 0
? Math.max(...spProgression.map((point) => point.sp))
: sp;
const shownMates = topMates.slice(
0,
topWeapons.length > 0 ? TOP_MATES_COUNT : TOP_MATES_COUNT_WITHOUT_WEAPONS,
);
return (
<GraphicContainer>
<GraphicHeader
avatarUrl={user.avatarUrl}
identiconInput={user.discordId}
titleRow={
<>
{user.countryCode ? (
<Flag countryCode={user.countryCode} tiny />
) : null}
<span className={graphicStyles.headerTitle}>{user.name}</span>
</>
}
subtitle={
<LocaleTimeRange
from={seasonDateRange.starts}
to={seasonDateRange.ends}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
<div className={styles.seasonBadge}>
{t("user:seasons.season")} {season}
</div>
}
/>
<div className={clsx(graphicStyles.box, styles.hero)}>
<TierImage tier={tier} width={92} />
<div>
<div className={styles.heroTierName}>
{tier.name}
{tier.isPlus ? "+" : ""}
</div>
<div className={styles.heroSp}>{sp.toFixed(1)}SP</div>
{peakSp.toFixed(1) === sp.toFixed(1) ? null : (
<div className={styles.heroPeak}>
{t("user:seasons.peak")} {peakSp.toFixed(1)}SP
</div>
)}
</div>
{typeof soloRank === "number" ? (
<div className={styles.rankBlock}>
<div className={styles.rankValue}>#{soloRank}</div>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.soloRank")}
</div>
</div>
) : null}
</div>
{teamRank ? (
<div className={clsx(graphicStyles.box, styles.teamRankRow)}>
{teamRank.team ? (
<Avatar
url={teamRank.team.logoUrl}
identiconInput={teamRank.team.name}
size="sm"
alt=""
/>
) : null}
<div className={styles.teamRankInfo}>
<div className={styles.teamRankTitle}>
{teamRank.team ? (
<span className={styles.teamRankName}>
{teamRank.team.name}
</span>
) : null}
<div
className={clsx(styles.teamRankSp, {
[styles.teamRankSpSecondary]: Boolean(teamRank.team),
})}
>
{teamRank.sp.toFixed(1)}SP
</div>
</div>
<div
className={clsx(styles.playersInline, styles.playersInlineStart)}
>
{teamRank.mates.map((mate) => (
<GraphicPlayerChip key={mate.name} player={mate} />
))}
</div>
</div>
{typeof teamRank.rank === "number" ? (
<div className={styles.rankBlock}>
<div className={styles.rankValue}>#{teamRank.rank}</div>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.teamRank")}
</div>
</div>
) : null}
</div>
) : null}
<GraphicStatsRow>
<GraphicStat label={t("user:seasons.summary.sets")}>
<GraphicWonLost won={setsWon} lost={setsLost} />
</GraphicStat>
<GraphicStat label={t("user:seasons.summary.maps")}>
<GraphicWonLost won={mapsWon} lost={mapsLost} />
</GraphicStat>
{clutch && clutch.total > 0 ? (
<GraphicStat label={t("user:seasons.summary.clutch")}>
<GraphicWonLost won={clutch.won} lost={clutch.total - clutch.won} />
</GraphicStat>
) : null}
<GraphicStat label={t("user:seasons.summary.winStreak")}>
{longestWinStreak}
</GraphicStat>
</GraphicStatsRow>
{spProgression.length >= CHART_POINTS_NEEDED ? (
<div className={graphicStyles.box}>
<SpChart points={spProgression} />
</div>
) : null}
{bestStage ? (
<StageBannerBox
stageId={bestStage.stageId}
className={clsx(graphicStyles.box, styles.bestStageRow)}
>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.bestStage")}
</div>
<div className={styles.bestStageName}>
{t(`game-misc:STAGE_${bestStage.stageId}`)}{" "}
<span className={styles.bestStageWinrate}>
{Math.round(bestStage.winratePercentage)}%
</span>
</div>
</StageBannerBox>
) : null}
<div className={styles.middleGrid}>
<div className={clsx(graphicStyles.box, styles.activityBox)}>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.activity")}
</div>
<ActivityCalendar
seasonDateRange={seasonDateRange}
activeDays={activeDays}
/>
<ActivityLegend />
</div>
<div className={styles.sideStack}>
{topWeapons.length > 0 ? (
<div className={graphicStyles.box}>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.topWeapons")}
</div>
<div className={styles.weaponsRow}>
{topWeapons.map((weapon) => (
<div key={weapon.weaponSplId} className={styles.weaponUsage}>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={52}
/>
<div className={graphicStyles.boxLabel}>
{Math.round(weapon.usagePercentage)}%
</div>
</div>
))}
</div>
</div>
) : null}
{shownMates.length > 0 ? (
<div
className={clsx(graphicStyles.box, {
[styles.matesBoxExpanded]: topWeapons.length === 0,
})}
>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.topMates")}
</div>
<div className={styles.matesList}>
{shownMates.map((mate) => (
<div key={mate.player.name} className={styles.mateRow}>
<Avatar
url={mate.avatarUrl}
identiconInput={mate.discordId}
size="xxs"
alt=""
/>
<div className={styles.mateName}>
{mate.player.countryCode ? (
<Flag countryCode={mate.player.countryCode} tiny />
) : null}
<span className={styles.mateNameText}>
{mate.player.name}
</span>
</div>
<div
className={clsx(graphicStyles.boxLabel, styles.mateSets)}
>
{t("user:seasons.summary.count.sets", {
count: mate.setsCount,
})}
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
{bestSets.length > 0 ? (
<>
<GraphicSectionDivider>
{t("user:seasons.summary.bestWins")}
</GraphicSectionDivider>
<ol className={styles.bestSetsList}>
{bestSets.map((set, index) => (
<li
key={`${index}-${set.context}`}
className={clsx(graphicStyles.box, styles.bestSetRow)}
>
<GraphicScore
ownScore={set.ownScore}
opponentScore={set.opponentScore}
/>
<div className={styles.setInfo}>
<div
className={clsx(graphicStyles.boxLabel, styles.setContext)}
>
{set.context}
</div>
<div
className={clsx(
styles.playersInline,
styles.playersInlineStart,
)}
>
{set.opponentPlayers.map((player) => (
<GraphicPlayerChip key={player.name} player={player} />
))}
</div>
</div>
<div className={styles.setSp}>
<div className={styles.setSpValue}>
{set.opponentSp.toFixed(1)}
</div>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.opponentSp")}
</div>
</div>
</li>
))}
</ol>
</>
) : null}
{bestTournament ? (
<>
<GraphicSectionDivider>
{t("user:seasons.summary.bestTournament")}
</GraphicSectionDivider>
<div className={clsx(graphicStyles.box, styles.tournamentRow)}>
<GraphicPlacementCell placement={bestTournament.placement} />
<Avatar
url={bestTournament.logoUrl}
identiconInput={bestTournament.name}
size="sm"
alt=""
/>
<div className={styles.tournamentInfo}>
<div className={styles.tournamentName}>{bestTournament.name}</div>
<div className={styles.tournamentMeta}>
{t("calendar:count.teams", {
count: bestTournament.teamsCount,
})}
</div>
</div>
{typeof bestTournament.tier === "number" ? (
<TierPill tier={bestTournament.tier} withoutAnimation />
) : null}
</div>
</>
) : null}
{qrCodeUrl ? null : (
<GraphicFooter>
<div>
{t("user:seasons.summary.count.sets", {
count: setsWon + setsLost,
})}{" "}
·{" "}
{t("user:seasons.summary.count.maps", {
count: mapsWon + mapsLost,
})}
</div>
<GraphicSiteUrl path={userSeasonsPage({ user, season })} />
</GraphicFooter>
)}
</GraphicContainer>
);
}
function SpChart({ points }: { points: Array<{ date: string; sp: number }> }) {
const { formatter } = useDateTimeFormat({ month: "short", day: "numeric" });
const gradientId = React.useId();
const times = points.map((point) => parseISO(point.date).getTime());
const minTime = times[0];
const maxTime = times[times.length - 1];
const sps = points.map((point) => point.sp);
const minSp = Math.min(...sps);
const maxSp = Math.max(...sps);
const innerWidth = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right;
const innerHeight = CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom;
const bottomY = CHART_HEIGHT - CHART_MARGIN.bottom;
const xAt = (time: number) =>
CHART_MARGIN.left +
((time - minTime) / Math.max(maxTime - minTime, 1)) * innerWidth;
const yAt = (spValue: number) =>
CHART_MARGIN.top +
(1 - (spValue - minSp) / Math.max(maxSp - minSp, 1)) * innerHeight;
const linePath = points
.map(
(point, index) =>
`${index === 0 ? "M" : "L"}${xAt(times[index]).toFixed(1)} ${yAt(point.sp).toFixed(1)}`,
)
.join(" ");
const areaPath = `${linePath} L${xAt(maxTime).toFixed(1)} ${bottomY} L${xAt(minTime).toFixed(1)} ${bottomY} Z`;
const peakIndex = sps.indexOf(maxSp);
const peakX = xAt(times[peakIndex]);
const peakY = yAt(maxSp);
const peakLabelX = Math.min(
Math.max(peakX, CHART_PEAK_LABEL_CLAMP),
CHART_WIDTH - CHART_PEAK_LABEL_CLAMP,
);
return (
<svg
className={styles.chart}
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
role="img"
aria-label="SP"
>
<defs>
{/* presentation attributes, not CSS: the image export does not style elements inside defs */}
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity={0.3} />
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<line
className={styles.chartGridLine}
x1={CHART_MARGIN.left}
y1={yAt(minSp)}
x2={CHART_WIDTH - CHART_MARGIN.right}
y2={yAt(minSp)}
/>
<path d={areaPath} fill={`url(#${gradientId})`} />
<path className={styles.chartLine} d={linePath} />
<circle className={styles.chartDot} cx={peakX} cy={peakY} r={4.5} />
<text
className={styles.chartPeakLabel}
x={peakLabelX}
y={peakY - 10}
textAnchor="middle"
>
{maxSp.toFixed(1)}SP
</text>
<text
className={styles.chartLabel}
x={CHART_MARGIN.left}
y={CHART_HEIGHT - 6}
>
{formatter.format(parseISO(points[0].date))}
</text>
<text
className={styles.chartLabel}
x={CHART_WIDTH - CHART_MARGIN.right}
y={CHART_HEIGHT - 6}
textAnchor="end"
>
{formatter.format(parseISO(points[points.length - 1].date))}
</text>
</svg>
);
}
function ActivityCalendar({
seasonDateRange,
activeDays,
}: {
seasonDateRange: { starts: Date; ends: Date };
activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>;
}) {
const activityByDay = new Map(
activeDays.map((day) => [day.date, day.activity]),
);
const seasonFirstDay = startOfDay(seasonDateRange.starts);
const days = eachDayOfInterval({
start: startOfWeek(seasonFirstDay, { weekStartsOn: 1 }),
end: seasonDateRange.ends,
});
return (
<div className={styles.calendar}>
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const beforeSeason = day.getTime() < seasonFirstDay.getTime();
return (
<div
key={key}
className={clsx(
styles.calendarCell,
activityClass(activityByDay.get(key)),
{ [styles.calendarCellHidden]: beforeSeason },
)}
/>
);
})}
</div>
);
}
function ActivityLegend() {
const { t } = useTranslation(["user"]);
return (
<div className={clsx(styles.calendarLegend, graphicStyles.boxLabel)}>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarSq)} />
SendouQ
</div>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarTournament)} />
{t("user:seasons.summary.activity.tournament")}
</div>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarBoth)} />
{t("user:seasons.summary.activity.both")}
</div>
</div>
);
}
function activityClass(activity?: SeasonSummaryGraphicActivity) {
if (!activity) return undefined;
switch (activity) {
case "sq":
return styles.calendarSq;
case "tournament":
return styles.calendarTournament;
case "both":
return styles.calendarBoth;
}
}

View File

@@ -0,0 +1,15 @@
.tierPillContainer {
display: inline-flex;
vertical-align: middle;
margin-inline-start: var(--s-2);
}
.organizationName {
max-width: 12rem;
overflow: hidden;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
white-space: nowrap;
text-overflow: ellipsis;
color: var(--graphic-text-dim);
}

View File

@@ -0,0 +1,144 @@
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { LocaleTime } from "~/components/LocaleTime";
import { TierPill } from "~/components/TierPill";
import { tournamentPage } from "~/utils/urls";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
GraphicSiteUrl,
type GraphicTeam,
GraphicTeamRow,
GraphicTeamsList,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import styles from "./TournamentResultsGraphic.module.css";
export interface TournamentResultsGraphicTeam extends GraphicTeam {
placement: number;
}
export function TournamentResultsGraphic({
tournamentId,
tournamentName,
startTime,
logoUrl,
tier,
organization,
teams,
teamsCount,
playersCount,
}: {
tournamentId: number;
tournamentName: string;
startTime: Date;
logoUrl?: string;
tier?: number;
organization?: { name: string; avatarUrl?: string };
teams: TournamentResultsGraphicTeam[];
teamsCount: number;
playersCount: number;
}) {
return (
<GraphicContainer>
<TournamentGraphicHeader
tournamentName={tournamentName}
startTime={startTime}
logoUrl={logoUrl}
tier={tier}
organization={organization}
/>
<GraphicTeamsList>
{teams.map((team) => (
<GraphicTeamRow
key={`${team.placement}-${team.name}`}
team={team}
highlighted={team.placement === 1}
leading={<GraphicPlacementCell placement={team.placement} />}
/>
))}
</GraphicTeamsList>
<TournamentGraphicFooter
teamsCount={teamsCount}
playersCount={playersCount}
path={tournamentPage(tournamentId)}
/>
</GraphicContainer>
);
}
export function TournamentGraphicHeader({
tournamentName,
startTime,
logoUrl,
tier,
organization,
}: {
tournamentName: string;
startTime: Date;
logoUrl?: string;
tier?: number;
organization?: { name: string; avatarUrl?: string };
}) {
return (
<GraphicHeader
avatarUrl={logoUrl}
identiconInput={tournamentName}
titleRow={
<span className={graphicStyles.headerTitle}>
{tournamentName}
{typeof tier === "number" ? (
<span className={styles.tierPillContainer}>
<TierPill tier={tier} withoutAnimation />
</span>
) : null}
</span>
}
subtitle={
<LocaleTime
date={startTime}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
organization ? (
<>
<span className={styles.organizationName}>{organization.name}</span>
<Avatar
url={organization.avatarUrl}
identiconInput={organization.name}
size="xs"
alt=""
/>
</>
) : null
}
/>
);
}
export function TournamentGraphicFooter({
teamsCount,
playersCount,
path,
}: {
teamsCount: number;
playersCount: number;
path: string;
}) {
const { t } = useTranslation(["calendar"]);
return (
<GraphicFooter>
<div>
{t("calendar:count.teams", { count: teamsCount })} ·{" "}
{t("calendar:count.players", { count: playersCount })}
</div>
<GraphicSiteUrl path={path} />
</GraphicFooter>
);
}

View File

@@ -0,0 +1,53 @@
.ownTeamRow {
grid-template-columns: 2.75rem 1fr auto;
background:
linear-gradient(
to right,
color-mix(in oklch, var(--graphic-accent) 16%, transparent),
transparent 40%
),
var(--graphic-row-bg);
border-color: color-mix(in oklch, var(--graphic-accent) 35%, transparent);
}
.matchRow {
grid-template-columns: 5.5rem 2.75rem 1fr auto;
}
.matchLeading {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.roundName {
text-align: center;
}
.seriesWin {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
width: 100%;
text-align: center;
}
.seriesWinName {
width: 100%;
overflow: hidden;
font-size: var(--font-sm);
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
}
.seriesWinDate {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--graphic-text-dim);
}
.seriesTitlesCount {
color: var(--graphic-first);
}

View File

@@ -0,0 +1,229 @@
import clsx from "clsx";
import { ArrowDown } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { LocaleTime } from "~/components/LocaleTime";
import { tournamentTeamPage } from "~/utils/urls";
import {
GraphicContainer,
GraphicPlacementCell,
GraphicScore,
GraphicSectionDivider,
GraphicStat,
GraphicStatsRow,
type GraphicTeam,
GraphicTeamRow,
GraphicTeamsList,
GraphicWonLost,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import {
TournamentGraphicFooter,
TournamentGraphicHeader,
type TournamentResultsGraphicTeam,
} from "./TournamentResultsGraphic";
import styles from "./TournamentRunGraphic.module.css";
const ROUND_NAME_ONE_LINE_MAX_LENGTH = 9;
const SERIES_WIN_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
};
export interface TournamentRunGraphicSeriesWin {
name: string;
startTime: Date;
}
export interface TournamentRunGraphicMatch {
opponent: GraphicTeam;
ownScore: number;
opponentScore: number;
roundName: string;
bracketName: string;
}
export function TournamentRunGraphic({
tournamentId,
tournamentTeamId,
tournamentName,
startTime,
logoUrl,
tier,
organization,
team,
seed,
matches,
teamsCount,
playersCount,
seriesWins,
}: {
tournamentId: number;
tournamentTeamId: number;
tournamentName: string;
startTime: Date;
logoUrl?: string;
tier?: number;
organization?: { name: string; avatarUrl?: string };
team: TournamentResultsGraphicTeam;
seed?: number;
matches: TournamentRunGraphicMatch[];
teamsCount: number;
playersCount: number;
seriesWins?: {
totalCount: number;
first: TournamentRunGraphicSeriesWin;
latest?: TournamentRunGraphicSeriesWin;
};
}) {
const { t } = useTranslation(["tournament"]);
const setsWon = matches.filter(
(match) => match.ownScore > match.opponentScore,
).length;
const setsLost = matches.length - setsWon;
const mapsWon = R.sumBy(matches, (match) => match.ownScore);
const mapsLost = R.sumBy(matches, (match) => match.opponentScore);
return (
<GraphicContainer>
<TournamentGraphicHeader
tournamentName={tournamentName}
startTime={startTime}
logoUrl={logoUrl}
tier={tier}
organization={organization}
/>
<GraphicTeamRow as="div" team={team} className={styles.ownTeamRow} />
<GraphicStatsRow>
<GraphicStat label={t("tournament:team.placement")}>
<GraphicPlacementCell placement={team.placement} />
</GraphicStat>
{typeof seed === "number" ? (
<GraphicStat label={t("tournament:team.seed")}>{seed}</GraphicStat>
) : null}
<GraphicStat label={t("tournament:run.sets")}>
<GraphicWonLost won={setsWon} lost={setsLost} />
</GraphicStat>
<GraphicStat label={t("tournament:run.maps")}>
<GraphicWonLost won={mapsWon} lost={mapsLost} />
</GraphicStat>
</GraphicStatsRow>
{team.placement === 1 && seriesWins ? (
<GraphicStatsRow>
<SeriesWinStat
label={t("tournament:run.firstTitle")}
win={seriesWins.first}
/>
{seriesWins.latest ? (
<SeriesWinStat
label={t("tournament:run.latestTitle")}
win={seriesWins.latest}
/>
) : null}
<GraphicStat label={t("tournament:run.seriesTitles")}>
<span className={styles.seriesTitlesCount}>
{seriesWins.totalCount}
</span>
</GraphicStat>
</GraphicStatsRow>
) : null}
<GraphicTeamsList>
{matches.map((match, index) => {
const previousMatch = matches[index - 1];
const qualifiedForBracket =
previousMatch && previousMatch.bracketName !== match.bracketName;
return (
<React.Fragment key={`${index}-${match.opponent.name}`}>
{qualifiedForBracket ? (
<GraphicSectionDivider as="li">
<ArrowDown size={14} />
{t("tournament:run.qualifiedFor", {
bracket: match.bracketName,
})}
</GraphicSectionDivider>
) : null}
<GraphicTeamRow
className={styles.matchRow}
team={match.opponent}
leading={
<div className={styles.matchLeading}>
<div
className={clsx(graphicStyles.boxLabel, styles.roundName)}
>
{roundNameLines(match.roundName).map((line) => (
<div key={line}>{line}</div>
))}
</div>
<GraphicScore
ownScore={match.ownScore}
opponentScore={match.opponentScore}
/>
</div>
}
/>
</React.Fragment>
);
})}
</GraphicTeamsList>
<TournamentGraphicFooter
teamsCount={teamsCount}
playersCount={playersCount}
path={tournamentTeamPage({ tournamentId, tournamentTeamId })}
/>
</GraphicContainer>
);
}
function roundNameLines(roundName: string) {
const words = roundName.split(" ");
if (
roundName.length <= ROUND_NAME_ONE_LINE_MAX_LENGTH ||
words.length === 1
) {
return [roundName];
}
const splitIndex =
R.firstBy(R.range(1, words.length), (index) =>
longestLineLength(words, index),
) ?? 1;
return [
words.slice(0, splitIndex).join(" "),
words.slice(splitIndex).join(" "),
];
}
function longestLineLength(words: string[], splitIndex: number) {
return Math.max(
words.slice(0, splitIndex).join(" ").length,
words.slice(splitIndex).join(" ").length,
);
}
function SeriesWinStat({
label,
win,
}: {
label: string;
win: TournamentRunGraphicSeriesWin;
}) {
return (
<GraphicStat label={label}>
<div className={styles.seriesWin}>
<div className={styles.seriesWinName}>{win.name}</div>
<LocaleTime
date={win.startTime}
options={SERIES_WIN_DATE_FORMAT_OPTIONS}
className={styles.seriesWinDate}
/>
</div>
</GraphicStat>
);
}