mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
289
apps/web-react/app/features/img-export/components/Graphic.tsx
Normal file
289
apps/web-react/app/features/img-export/components/Graphic.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user