opencs2-dataset-viewer / src /lib /components /player-grid.svelte
blanchon's picture
Player grid: gate skull on availability, not on round-final survival
9d3dfa1
<script lang="ts">
import type { PreviewChunk } from '$lib/types';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import { Switch } from '$lib/components/ui/switch';
import { Label } from '$lib/components/ui/label';
import { cn } from '$lib/utils';
import SkullIcon from 'phosphor-svelte/lib/SkullIcon';
import ShieldIcon from 'phosphor-svelte/lib/ShieldIcon';
import SwordIcon from 'phosphor-svelte/lib/SwordIcon';
import { formatDuration } from '$lib/utils/format';
import { playerColor } from '$lib/utils/player-colors';
interface Props {
previews: PreviewChunk[];
currentPlayer: number;
preloadedPlayers: Set<number>;
availablePlayers: Set<number>;
playerDurations: Map<number, number>;
preloadAll: boolean;
onSelect: (player: number) => void;
onPreloadAllChange: (enabled: boolean) => void;
}
let {
previews,
currentPlayer,
preloadedPlayers,
availablePlayers,
playerDurations,
preloadAll,
onSelect,
onPreloadAllChange
}: Props = $props();
type PlayerSummary = {
player: number;
side: 'CT' | 'T' | string;
weapon: string;
survived: boolean;
duration: number;
};
const players = $derived.by<PlayerSummary[]>(() => {
const map = new Map<number, PreviewChunk[]>();
for (const p of previews) {
if (!map.has(p.player)) map.set(p.player, []);
map.get(p.player)!.push(p);
}
const out: PlayerSummary[] = [];
for (const [player, chunks] of map.entries()) {
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
const last = chunks[chunks.length - 1];
out.push({
player,
side: last.player_side,
weapon: last.primary_weapon,
survived: last.survived_chunk,
duration: playerDurations.get(player) ?? 0
});
}
out.sort((a, b) => a.player - b.player);
return out;
});
const ctPlayers = $derived(players.filter((p) => p.side === 'CT'));
const tPlayers = $derived(players.filter((p) => p.side === 'T'));
</script>
{#snippet playerItem(p: PlayerSummary)}
{@const ct = p.side === 'CT'}
{@const ready = preloadedPlayers.has(p.player)}
{@const available = availablePlayers.has(p.player)}
<ToggleGroup.Item
data-ready={available && ready ? true : undefined}
data-side={ct ? 'ct' : 't'}
value={String(p.player)}
aria-label="Player {p.player} ({p.weapon || 'no weapon'})"
title={available
? `Player ${p.player}${p.weapon || 'no weapon'}`
: `Player ${p.player} died at ${formatDuration(p.duration)} — not available at current time`}
disabled={!available}
class={cn(
'group flex h-auto min-w-0 flex-1 basis-0 items-center justify-start gap-2 rounded-md border px-2 py-1.5 text-left text-sm transition',
'disabled:cursor-not-allowed disabled:opacity-40 data-[state=on]:border-current data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
'data-[side=ct]:data-[state=on]:border-sky-500 data-[side=ct]:data-[state=on]:bg-sky-500/10 data-[side=ct]:data-[state=on]:text-foreground',
'data-[side=t]:data-[state=on]:border-amber-500 data-[side=t]:data-[state=on]:bg-amber-500/10 data-[side=t]:data-[state=on]:text-foreground',
'data-[ready=true]:ring-2 data-[ready=true]:ring-emerald-500/60 data-[ready=true]:ring-offset-1 data-[ready=true]:ring-offset-background'
)}
>
<span
class="flex size-7 shrink-0 items-center justify-center rounded-sm font-mono text-xs font-bold text-white/95 shadow-sm ring-1 ring-black/20"
style="background-color: {playerColor(p.player)};"
>
{p.player}
</span>
<div class="min-w-0 flex-1">
<div class="truncate font-medium">Player {p.player}</div>
{#if !available}
<div class="text-[10px] text-muted-foreground tabular-nums">
died · {formatDuration(p.duration)}
</div>
{/if}
</div>
{#if p.survived}
<ShieldIcon size={14} weight="duotone" class="text-emerald-500" />
{:else if !available}
<SkullIcon size={14} weight="duotone" class="text-rose-500" />
{/if}
</ToggleGroup.Item>
{/snippet}
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<div class="text-xs tracking-wide text-muted-foreground uppercase">Player perspectives</div>
<div class="flex items-center gap-2">
<Label
for="preload-all-players"
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>
Preload all
</Label>
<Switch
id="preload-all-players"
size="sm"
checked={preloadAll}
onCheckedChange={(v) => onPreloadAllChange(!!v)}
/>
</div>
</div>
<ToggleGroup.Root
type="single"
value={String(currentPlayer)}
onValueChange={(v) => v && onSelect(Number(v))}
variant="outline"
spacing={1}
aria-label="Player perspective"
class="w-full! flex-col gap-2"
>
<div class="w-full">
<div
class="mb-1 flex items-center gap-1.5 text-[10px] font-semibold tracking-wide text-sky-700 uppercase dark:text-sky-400"
>
<ShieldIcon size={11} weight="duotone" /> Counter-Terrorists
</div>
<div class="flex w-full items-stretch gap-1.5">
{#each ctPlayers as p (p.player)}
{@render playerItem(p)}
{/each}
</div>
</div>
<div class="w-full">
<div
class="mb-1 flex items-center gap-1.5 text-[10px] font-semibold tracking-wide text-amber-700 uppercase dark:text-amber-400"
>
<SwordIcon size={11} weight="duotone" /> Terrorists
</div>
<div class="flex w-full items-stretch gap-1.5">
{#each tPlayers as p (p.player)}
{@render playerItem(p)}
{/each}
</div>
</div>
</ToggleGroup.Root>
</div>