mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-05 20:56:13 -05:00
633 lines
15 KiB
TypeScript
633 lines
15 KiB
TypeScript
import clsx from "clsx";
|
|
import {
|
|
ArrowRight,
|
|
MousePointerClick,
|
|
RefreshCcw,
|
|
TrendingUp,
|
|
Users,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables";
|
|
import { useHydrated } from "~/hooks/useHydrated";
|
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
|
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
|
import type {
|
|
MainWeaponId,
|
|
ModeShort,
|
|
StageId,
|
|
} from "~/modules/in-game-lists/types";
|
|
import type { CommonUser } from "~/utils/kysely.server";
|
|
import { Avatar } from "../Avatar";
|
|
import { SendouButton } from "../elements/Button";
|
|
import { SendouPopover } from "../elements/Popover";
|
|
import { ModeImage, StageImage } from "../Image";
|
|
import styles from "./MatchTimeline.module.css";
|
|
import { type InferredSubstitution, inferSubstitutions } from "./utils";
|
|
import { WeaponPool } from "./WeaponPool";
|
|
|
|
const LONG_TEAM_NAME_THRESHOLD = 16;
|
|
|
|
type MatchSide = "ALPHA" | "BRAVO";
|
|
|
|
export interface TimelineTeam {
|
|
name: string;
|
|
avatar?: string;
|
|
}
|
|
|
|
export interface TimelineMap {
|
|
stageId: StageId;
|
|
mode: ModeShort;
|
|
timestamp: number;
|
|
winner: MatchSide;
|
|
rosters: {
|
|
alpha: CommonUser[];
|
|
bravo: CommonUser[];
|
|
};
|
|
weapons?: {
|
|
alpha: Array<MainWeaponId | null>;
|
|
bravo: Array<MainWeaponId | null>;
|
|
};
|
|
/** Optional point values [alpha, bravo] */
|
|
points?: [number, number];
|
|
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
|
|
pickedBy?: MatchSide;
|
|
}
|
|
|
|
interface TimelineSpMember {
|
|
user: CommonUser;
|
|
skillDifference: UserSkillDifference;
|
|
}
|
|
|
|
export interface TimelineSpChanges {
|
|
alpha: {
|
|
members: TimelineSpMember[];
|
|
skillDifference?: GroupSkillDifference;
|
|
};
|
|
bravo: {
|
|
members: TimelineSpMember[];
|
|
skillDifference?: GroupSkillDifference;
|
|
};
|
|
}
|
|
|
|
export interface TimelinePickBanEvent {
|
|
/** "PICK" covers MODE_PICK (and the rare trailing-bucket map PICK); "BAN" covers map and mode bans. */
|
|
kind: "PICK" | "BAN";
|
|
/** Consecutive events of the same kind get merged into one row, regardless of side. */
|
|
alphaEntries: Array<{ stageId?: StageId; mode?: ModeShort }>;
|
|
bravoEntries: Array<{ stageId?: StageId; mode?: ModeShort }>;
|
|
}
|
|
|
|
export interface MatchTimelineProps {
|
|
teams: { alpha: TimelineTeam; bravo: TimelineTeam };
|
|
score: { alpha: number; bravo: number };
|
|
maps: TimelineMap[];
|
|
spChanges?: TimelineSpChanges;
|
|
/** When true, render only the team + score header (no per-map rows or SP section). */
|
|
compact?: boolean;
|
|
/** When true, the match is still in progress; renders a small LIVE label under the score. */
|
|
isOngoing?: boolean;
|
|
/**
|
|
* Pick/ban events keyed by the slot they precede. Length = `maps.length + 1`.
|
|
* Bucket `i` renders above map row `i`; the trailing bucket renders after the
|
|
* last map row (covers events made after the latest result, or the
|
|
* pick/ban-only state with no maps reported yet).
|
|
*/
|
|
pickBanRowsBySlot?: TimelinePickBanEvent[][];
|
|
}
|
|
|
|
export function MatchTimeline({
|
|
teams,
|
|
score,
|
|
maps,
|
|
spChanges,
|
|
compact = false,
|
|
isOngoing = false,
|
|
pickBanRowsBySlot,
|
|
}: MatchTimelineProps) {
|
|
return (
|
|
<div className={styles.root}>
|
|
<TimelineHeader
|
|
teams={teams}
|
|
score={score}
|
|
maps={maps}
|
|
isOngoing={isOngoing}
|
|
/>
|
|
{compact
|
|
? null
|
|
: maps.map((map, i) => {
|
|
const previousMap = maps[i - 1];
|
|
const substitutions = previousMap
|
|
? inferSubstitutions(previousMap.rosters, map.rosters)
|
|
: [];
|
|
const pickBanRows = pickBanRowsBySlot?.[i] ?? [];
|
|
|
|
return (
|
|
<div key={i} className="contents">
|
|
{pickBanRows.map((event, j) => (
|
|
<TimelinePickBanRow key={`pb-${j}`} event={event} />
|
|
))}
|
|
{substitutions.map((sub, j) => (
|
|
<TimelineSubstitutionRow key={j} substitution={sub} />
|
|
))}
|
|
<TimelineMapRow map={map} />
|
|
</div>
|
|
);
|
|
})}
|
|
{!compact && pickBanRowsBySlot
|
|
? (pickBanRowsBySlot[maps.length] ?? []).map((event, j) => (
|
|
<TimelinePickBanRow key={`pb-trailing-${j}`} event={event} />
|
|
))
|
|
: null}
|
|
{!compact && spChanges ? (
|
|
<TimelineSpSection spChanges={spChanges} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineHeader({
|
|
teams,
|
|
score,
|
|
maps,
|
|
isOngoing,
|
|
}: Pick<MatchTimelineProps, "teams" | "score" | "maps" | "isOngoing">) {
|
|
const { t } = useTranslation(["q"]);
|
|
const initialRosters = maps[0]?.rosters;
|
|
|
|
return (
|
|
<div className={styles.header}>
|
|
<div className={styles.headerTeam}>
|
|
<div
|
|
className={clsx(styles.headerTeamName, {
|
|
[styles.headerTeamNameLong]:
|
|
teams.alpha.name.length > LONG_TEAM_NAME_THRESHOLD,
|
|
})}
|
|
>
|
|
{teams.alpha.name}
|
|
</div>
|
|
{initialRosters ? (
|
|
<div className={styles.headerAvatars}>
|
|
{initialRosters.alpha.map((user) => (
|
|
<Avatar key={user.id} user={user} size="xxs" />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className={styles.headerScore}>
|
|
<span className={styles.headerScoreValue}>
|
|
{score.alpha}-{score.bravo}
|
|
</span>
|
|
{isOngoing ? (
|
|
<span className={styles.headerScoreLive}>
|
|
{t("q:match.timeline.live")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className={clsx(styles.headerTeam, styles.headerTeamBravo)}>
|
|
<div
|
|
className={clsx(styles.headerTeamName, {
|
|
[styles.headerTeamNameLong]:
|
|
teams.bravo.name.length > LONG_TEAM_NAME_THRESHOLD,
|
|
})}
|
|
>
|
|
{teams.bravo.name}
|
|
</div>
|
|
{initialRosters ? (
|
|
<div className={styles.headerAvatars}>
|
|
{initialRosters.bravo.map((user) => (
|
|
<Avatar key={user.id} user={user} size="xxs" />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineMapRow({ map }: { map: TimelineMap }) {
|
|
const { t } = useTranslation(["game-misc"]);
|
|
const isHydrated = useHydrated();
|
|
const { formatTime } = useTimeFormat();
|
|
|
|
const alphaPoints = map.points?.[0];
|
|
const bravoPoints = map.points?.[1];
|
|
|
|
return (
|
|
<div className={styles.mapEvent}>
|
|
<div className={styles.mapSide}>
|
|
<SideResult
|
|
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
|
|
points={alphaPoints}
|
|
weapons={map.weapons?.alpha}
|
|
isPicked={map.pickedBy === "ALPHA"}
|
|
/>
|
|
</div>
|
|
<div className={styles.mapCenter}>
|
|
<time className={styles.mapTimestamp}>
|
|
{isHydrated ? (
|
|
formatTime(new Date(map.timestamp))
|
|
) : (
|
|
<div className="invisible">X</div>
|
|
)}
|
|
</time>
|
|
<StageImage
|
|
stageId={map.stageId}
|
|
width={80}
|
|
className={styles.mapStageImage}
|
|
/>
|
|
<div className={styles.mapLabel}>
|
|
<ModeImage mode={map.mode} size={14} />
|
|
<span>{shortStageName(t(`game-misc:STAGE_${map.stageId}`))}</span>
|
|
</div>
|
|
</div>
|
|
<div className={styles.mapSide}>
|
|
<SideResult
|
|
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
|
|
points={bravoPoints}
|
|
weapons={map.weapons?.bravo}
|
|
isPicked={map.pickedBy === "BRAVO"}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SideResult({
|
|
result,
|
|
points,
|
|
weapons,
|
|
isPicked,
|
|
}: {
|
|
result: "WIN" | "LOSS";
|
|
points?: number;
|
|
weapons?: Array<MainWeaponId | null>;
|
|
isPicked?: boolean;
|
|
}) {
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
return (
|
|
<div className={styles.sideResult}>
|
|
<div className={styles.resultHeader}>
|
|
{isPicked ? (
|
|
<MousePointerClick
|
|
size={14}
|
|
className={result === "WIN" ? "text-success" : "text-error"}
|
|
aria-label={t("q:match.timeline.picked")}
|
|
/>
|
|
) : null}
|
|
<span
|
|
className={clsx(
|
|
styles.resultLabel,
|
|
result === "WIN" ? "text-success" : "text-error",
|
|
)}
|
|
>
|
|
{result === "WIN"
|
|
? t("q:match.timeline.win")
|
|
: t("q:match.timeline.loss")}
|
|
</span>
|
|
{points === 100 ? (
|
|
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
|
|
) : null}
|
|
</div>
|
|
{weapons ? <WeaponPool weapons={weapons} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineEventRow({
|
|
icon,
|
|
alphaContent,
|
|
bravoContent,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
alphaContent: React.ReactNode;
|
|
bravoContent: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={styles.eventRow}>
|
|
<div className={styles.eventAlpha}>{alphaContent}</div>
|
|
<div className={styles.subCenter}>{icon}</div>
|
|
<div>{bravoContent}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelinePickBanRow({ event }: { event: TimelinePickBanEvent }) {
|
|
const isPick = event.kind === "PICK";
|
|
const icon = isPick ? (
|
|
<MousePointerClick
|
|
size={32}
|
|
className={clsx(styles.eventIcon, styles.pickIcon)}
|
|
/>
|
|
) : (
|
|
<X size={32} className={clsx(styles.eventIcon, styles.banIcon)} />
|
|
);
|
|
|
|
return (
|
|
<TimelineEventRow
|
|
icon={icon}
|
|
alphaContent={
|
|
event.alphaEntries.length > 0 ? (
|
|
<PickBanGroup entries={event.alphaEntries} side="ALPHA" />
|
|
) : null
|
|
}
|
|
bravoContent={
|
|
event.bravoEntries.length > 0 ? (
|
|
<PickBanGroup entries={event.bravoEntries} side="BRAVO" />
|
|
) : null
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function PickBanGroup({
|
|
entries,
|
|
side,
|
|
}: {
|
|
entries: Array<{ stageId?: StageId; mode?: ModeShort }>;
|
|
side: MatchSide;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={clsx(styles.pickBanGroup, {
|
|
[styles.pickBanGroupBravo]: side === "BRAVO",
|
|
})}
|
|
>
|
|
{entries.map((entry, i) => (
|
|
<PickBanEntry key={i} entry={entry} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PickBanEntry({
|
|
entry,
|
|
}: {
|
|
entry: { stageId?: StageId; mode?: ModeShort };
|
|
}) {
|
|
if (entry.stageId !== undefined) {
|
|
return (
|
|
<StageImage
|
|
stageId={entry.stageId}
|
|
width={56}
|
|
className={styles.pickBanStageImage}
|
|
/>
|
|
);
|
|
}
|
|
if (entry.mode !== undefined) {
|
|
return (
|
|
<div className={styles.pickBanModeTile}>
|
|
<ModeImage mode={entry.mode} size={24} />
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function TimelineSubstitutionRow({
|
|
substitution,
|
|
}: {
|
|
substitution: InferredSubstitution;
|
|
}) {
|
|
return (
|
|
<TimelineEventRow
|
|
icon={<RefreshCcw size={32} className={styles.eventIcon} />}
|
|
alphaContent={
|
|
substitution.side === "ALPHA" ? (
|
|
<SubstitutionDetail substitution={substitution} />
|
|
) : null
|
|
}
|
|
bravoContent={
|
|
substitution.side === "BRAVO" ? (
|
|
<SubstitutionDetail substitution={substitution} />
|
|
) : null
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SubstitutionDetail({
|
|
substitution,
|
|
}: {
|
|
substitution: InferredSubstitution;
|
|
}) {
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
return (
|
|
<div className={styles.subDetail}>
|
|
<span className={styles.subLabelOut}>{t("q:match.timeline.out")}</span>
|
|
<div className="stack horizontal items-center sm">
|
|
<Avatar user={substitution.playerOut} size="xxxs" />
|
|
<span className={styles.subPlayerName}>
|
|
{substitution.playerOut.username}
|
|
</span>
|
|
</div>
|
|
<span className={styles.subLabelIn}>{t("q:match.timeline.in")}</span>
|
|
<div className="stack horizontal items-center sm">
|
|
<Avatar user={substitution.playerIn} size="xxxs" />
|
|
<span className={styles.subPlayerName}>
|
|
{substitution.playerIn.username}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineSpSection({ spChanges }: { spChanges: TimelineSpChanges }) {
|
|
const alphaMembersWithDiff = spChanges.alpha.members.filter(
|
|
(m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0,
|
|
);
|
|
const bravoMembersWithDiff = spChanges.bravo.members.filter(
|
|
(m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0,
|
|
);
|
|
|
|
const maxMemberRows = Math.max(
|
|
alphaMembersWithDiff.length,
|
|
bravoMembersWithDiff.length,
|
|
);
|
|
|
|
if (
|
|
maxMemberRows === 0 &&
|
|
!spChanges.alpha.skillDifference &&
|
|
!spChanges.bravo.skillDifference
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.spSection}>
|
|
<div className={styles.spColumn}>
|
|
{alphaMembersWithDiff.map((m) => (
|
|
<SpMemberDetail key={m.user.id} member={m} />
|
|
))}
|
|
{spChanges.alpha.skillDifference ? (
|
|
<SpTeamDetail skillDifference={spChanges.alpha.skillDifference} />
|
|
) : null}
|
|
</div>
|
|
<div className={styles.spIcon}>
|
|
<TrendingUp size={32} className={styles.eventIcon} />
|
|
</div>
|
|
<div className={styles.spColumn}>
|
|
{bravoMembersWithDiff.map((m) => (
|
|
<SpMemberDetail key={m.user.id} member={m} />
|
|
))}
|
|
{spChanges.bravo.skillDifference ? (
|
|
<SpTeamDetail skillDifference={spChanges.bravo.skillDifference} />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SpMemberDetail({ member }: { member: TimelineSpMember }) {
|
|
if (member.skillDifference.calculated) {
|
|
const { spDiff, oldSp, newSp } = member.skillDifference;
|
|
const isPositive = spDiff > 0;
|
|
const arrow = isPositive ? "▲" : "▼";
|
|
|
|
return (
|
|
<div className={styles.spDetail}>
|
|
<Avatar user={member.user} size="xxs" />
|
|
<SpDeltaTrigger
|
|
arrow={arrow}
|
|
isPositive={isPositive}
|
|
value={Math.abs(spDiff)}
|
|
oldSp={oldSp}
|
|
newSp={newSp}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (
|
|
member.skillDifference.matchesCount ===
|
|
member.skillDifference.matchesCountNeeded
|
|
) {
|
|
return (
|
|
<div className={styles.spDetail}>
|
|
<Avatar user={member.user} size="xxs" />
|
|
<div className={styles.spDetailContent}>
|
|
<span className={styles.spCalculatingIcon}>◆</span>
|
|
<span>
|
|
{member.skillDifference.newSp ? (
|
|
<>{member.skillDifference.newSp}SP</>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.spDetail}>
|
|
<Avatar user={member.user} size="xxs" />
|
|
<div className={styles.spDetailContent}>
|
|
<span className={styles.spCalculatingIcon}>◆</span>
|
|
<span>
|
|
{member.skillDifference.matchesCount}/
|
|
{member.skillDifference.matchesCountNeeded}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SpTeamDetail({
|
|
skillDifference,
|
|
}: {
|
|
skillDifference: GroupSkillDifference;
|
|
}) {
|
|
if (skillDifference.calculated) {
|
|
const { oldSp, newSp } = skillDifference;
|
|
const diff = newSp - oldSp;
|
|
const isPositive = diff > 0;
|
|
const arrow = isPositive ? "▲" : "▼";
|
|
|
|
return (
|
|
<div className={styles.spDetail}>
|
|
<div className={styles.spTeamIcon}>
|
|
<Users size={16} />
|
|
</div>
|
|
<SpDeltaTrigger
|
|
arrow={arrow}
|
|
isPositive={isPositive}
|
|
value={Math.abs(diff)}
|
|
oldSp={oldSp}
|
|
newSp={newSp}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (skillDifference.newSp) {
|
|
return (
|
|
<div className={styles.spDetail}>
|
|
<div className={styles.spTeamIcon}>
|
|
<Users size={16} />
|
|
</div>
|
|
<div className={styles.spDetailContent}>
|
|
<span className={styles.spCalculatingIcon}>◆</span>
|
|
<span>{skillDifference.newSp}SP</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.spDetail}>
|
|
<div className={styles.spTeamIcon}>
|
|
<Users size={16} />
|
|
</div>
|
|
<div className={styles.spDetailContent}>
|
|
<span className={styles.spCalculatingIcon}>◆</span>
|
|
<span>
|
|
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SpDeltaTrigger({
|
|
arrow,
|
|
isPositive,
|
|
value,
|
|
oldSp,
|
|
newSp,
|
|
}: {
|
|
arrow: string;
|
|
isPositive: boolean;
|
|
value: number;
|
|
oldSp?: number;
|
|
newSp?: number;
|
|
}) {
|
|
const arrowClass = isPositive ? "text-success" : "text-warning";
|
|
|
|
if (oldSp === undefined || newSp === undefined) {
|
|
return (
|
|
<div className={styles.spDetailContent}>
|
|
<span className={arrowClass}>{arrow}</span>
|
|
<span>{value}SP</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SendouPopover
|
|
trigger={
|
|
<SendouButton variant="minimal" className={styles.spDeltaTrigger}>
|
|
<span className={arrowClass}>{arrow}</span>
|
|
<span>{value}SP</span>
|
|
</SendouButton>
|
|
}
|
|
>
|
|
<div className={styles.spRawPopover}>
|
|
<span>{oldSp}SP</span>
|
|
<ArrowRight size={16} />
|
|
<span>{newSp}SP</span>
|
|
</div>
|
|
</SendouPopover>
|
|
);
|
|
}
|