Spaces:
Running
Running
| <script lang="ts"> | |
| import { Skeleton } from '$lib/components/ui/skeleton'; | |
| import * as ToggleGroup from '$lib/components/ui/toggle-group'; | |
| import { cn } from '$lib/utils'; | |
| import { | |
| loadMapData, | |
| radarUrl, | |
| hasLowerFloor, | |
| floorForZ, | |
| worldToImage, | |
| yawToScreenDeg, | |
| RADAR_PX, | |
| type MapData, | |
| type Floor | |
| } from '$lib/utils/map'; | |
| type Player = { | |
| steamid: string; | |
| name: string; | |
| team_num: number; | |
| is_alive: boolean; | |
| health: number; | |
| X: number; | |
| Y: number; | |
| Z: number; | |
| yaw?: number; | |
| color: string; | |
| slot?: number; | |
| }; | |
| type Props = { | |
| mapName: string; | |
| players: Player[]; | |
| activePlayer?: number | null; | |
| availablePlayers?: Set<number> | null; | |
| onSelect?: (player: number) => void; | |
| size?: number; | |
| floor?: 'upper' | 'lower' | 'auto'; | |
| class?: string; | |
| }; | |
| let { | |
| mapName, | |
| players, | |
| activePlayer = null, | |
| availablePlayers = null, | |
| onSelect, | |
| size, | |
| floor = 'auto', | |
| class: className = '' | |
| }: Props = $props(); | |
| let allMaps = $state<Record<string, MapData> | null>(null); | |
| let userFloor = $state<Floor | null>(null); | |
| $effect(() => { | |
| let cancelled = false; | |
| loadMapData() | |
| .then((d) => { | |
| if (!cancelled) allMaps = d; | |
| }) | |
| .catch(() => {}); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }); | |
| const map = $derived(allMaps?.[mapName] ?? null); | |
| const lowerAvailable = $derived(map ? hasLowerFloor(map) : false); | |
| const autoFloor = $derived.by<Floor>(() => { | |
| if (!map || !lowerAvailable) return 'upper'; | |
| let upper = 0; | |
| let lower = 0; | |
| for (const p of players) { | |
| if (!p.is_alive) continue; | |
| if (floorForZ(map, p.Z) === 'lower') lower++; | |
| else upper++; | |
| } | |
| return lower > upper ? 'lower' : 'upper'; | |
| }); | |
| const displayedFloor = $derived<Floor>( | |
| userFloor ?? (floor === 'auto' ? autoFloor : (floor as Floor)) | |
| ); | |
| const toggleValue = $derived<Floor>(displayedFloor); | |
| </script> | |
| <div | |
| class={cn('relative', size === undefined && 'aspect-square w-full', className)} | |
| style={size === undefined ? undefined : `width: ${size}px; height: ${size}px;`} | |
| > | |
| {#if !map} | |
| <Skeleton class="size-full rounded-md" /> | |
| {:else} | |
| <img | |
| src={radarUrl(mapName, displayedFloor)} | |
| alt="{mapName} radar" | |
| class="absolute inset-0 size-full rounded-md opacity-70 select-none" | |
| draggable="false" | |
| /> | |
| <svg | |
| viewBox="0 0 {RADAR_PX} {RADAR_PX}" | |
| class="absolute inset-0 size-full" | |
| aria-label="Player positions on {mapName}" | |
| > | |
| {#each players as p (p.steamid)} | |
| {@const { px, py } = worldToImage(map, p.X, p.Y)} | |
| {@const playerFloor = floorForZ(map, p.Z)} | |
| {@const onDisplayed = !lowerAvailable || playerFloor === displayedFloor} | |
| {@const baseOpacity = onDisplayed ? 1 : 0.35} | |
| {@const isActive = activePlayer !== null && p.slot === activePlayer} | |
| {@const r = isActive ? 42 : 32} | |
| {@const fontSize = isActive ? 38 : 30} | |
| {@const coneLen = isActive ? 110 : 90} | |
| {@const coneHalf = isActive ? 38 : 32} | |
| {@const slotKnown = p.slot !== undefined} | |
| {@const available = | |
| !availablePlayers || (slotKnown && availablePlayers.has(p.slot as number))} | |
| {@const clickable = !!onSelect && slotKnown && available} | |
| <!-- svelte-ignore a11y_click_events_have_key_events --> | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <!-- svelte-ignore a11y_no_noninteractive_tabindex --> | |
| <g | |
| class="mp-marker" | |
| data-active={isActive || undefined} | |
| data-clickable={clickable || undefined} | |
| data-unavailable={!!onSelect && slotKnown && !available ? true : undefined} | |
| opacity={baseOpacity} | |
| role={clickable ? 'button' : undefined} | |
| tabindex={clickable ? 0 : undefined} | |
| aria-label={slotKnown ? `Switch to player ${p.slot}` : undefined} | |
| onclick={clickable ? () => onSelect?.(p.slot as number) : undefined} | |
| onkeydown={clickable | |
| ? (e: KeyboardEvent) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| onSelect?.(p.slot as number); | |
| } | |
| } | |
| : undefined} | |
| > | |
| <title>{p.name}{p.slot !== undefined ? ` (#${p.slot})` : ''}</title> | |
| {#if clickable || isActive} | |
| <!-- Hit-target + hover ring. Transparent stroke by default; the | |
| :hover style flips it on. Larger than the badge so the | |
| click area is generous. --> | |
| <circle | |
| class="mp-marker__hit" | |
| cx={px} | |
| cy={py} | |
| r={r + 14} | |
| fill="transparent" | |
| stroke={p.color} | |
| stroke-opacity="0" | |
| stroke-width="3" | |
| /> | |
| {/if} | |
| {#if p.is_alive} | |
| {#if p.yaw !== undefined} | |
| <!-- Vision cone: wide + faint fill, soft edge. Reads at a | |
| glance without overpowering the badge. --> | |
| <polygon | |
| points="0,-{coneHalf} {coneLen},0 0,{coneHalf}" | |
| fill={p.color} | |
| fill-opacity="0.18" | |
| stroke={p.color} | |
| stroke-opacity="0.55" | |
| stroke-width="2" | |
| transform="translate({px} {py}) rotate({yawToScreenDeg(p.yaw)})" | |
| /> | |
| {/if} | |
| {#if isActive} | |
| <!-- Active player halo: a white inner ring + colored outer ring. --> | |
| <circle | |
| cx={px} | |
| cy={py} | |
| r={r + 7} | |
| fill="none" | |
| stroke="#fff" | |
| stroke-width="2.5" | |
| stroke-opacity="0.9" | |
| /> | |
| <circle | |
| cx={px} | |
| cy={py} | |
| r={r + 12} | |
| fill="none" | |
| stroke={p.color} | |
| stroke-width="2.5" | |
| stroke-opacity="0.75" | |
| /> | |
| {/if} | |
| <circle | |
| cx={px} | |
| cy={py} | |
| {r} | |
| fill={p.color} | |
| stroke="#0b0f14" | |
| stroke-width={isActive ? 4 : 3} | |
| /> | |
| {#if p.slot !== undefined} | |
| <text | |
| x={px} | |
| y={py} | |
| text-anchor="middle" | |
| dominant-baseline="central" | |
| font-size={fontSize} | |
| font-weight="800" | |
| fill="#fff" | |
| stroke="#0b0f14" | |
| stroke-width="0.9" | |
| paint-order="stroke" | |
| style="pointer-events: none; user-select: none;" | |
| > | |
| {p.slot} | |
| </text> | |
| {/if} | |
| {:else} | |
| <g | |
| opacity={baseOpacity * 0.55} | |
| transform="translate({px} {py})" | |
| stroke={p.color} | |
| stroke-width="3.5" | |
| fill="none" | |
| > | |
| <circle r="18" /> | |
| <line x1="-10" y1="-10" x2="10" y2="10" /> | |
| <line x1="-10" y1="10" x2="10" y2="-10" /> | |
| </g> | |
| {/if} | |
| </g> | |
| {/each} | |
| </svg> | |
| {#if lowerAvailable} | |
| <div class="absolute top-2 right-2"> | |
| <ToggleGroup.Root | |
| type="single" | |
| value={toggleValue} | |
| onValueChange={(v) => { | |
| if (v === 'upper' || v === 'lower') userFloor = v; | |
| }} | |
| variant="outline" | |
| size="sm" | |
| spacing={1} | |
| aria-label="Floor" | |
| class="bg-background/80 backdrop-blur-sm" | |
| > | |
| <ToggleGroup.Item value="upper" aria-label="Upper floor" class="text-xs"> | |
| Upper | |
| </ToggleGroup.Item> | |
| <ToggleGroup.Item value="lower" aria-label="Lower floor" class="text-xs"> | |
| Lower | |
| </ToggleGroup.Item> | |
| </ToggleGroup.Root> | |
| </div> | |
| {/if} | |
| {/if} | |
| </div> | |
| <style> | |
| .mp-marker[data-clickable='true'] { | |
| cursor: pointer; | |
| } | |
| .mp-marker[data-unavailable='true'] { | |
| cursor: not-allowed; | |
| } | |
| /* Hover & focus on a clickable marker: light up the hit-ring and lift the | |
| group with a subtle drop-shadow so it feels selectable. */ | |
| .mp-marker[data-clickable='true']:hover .mp-marker__hit, | |
| .mp-marker[data-clickable='true']:focus-visible .mp-marker__hit { | |
| stroke-opacity: 0.9; | |
| } | |
| .mp-marker[data-clickable='true'] { | |
| transition: filter 120ms ease-out; | |
| } | |
| .mp-marker[data-clickable='true']:hover, | |
| .mp-marker[data-clickable='true']:focus-visible { | |
| filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45)); | |
| } | |
| .mp-marker:focus { | |
| outline: none; | |
| } | |
| .mp-marker:focus-visible { | |
| outline: none; | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| .mp-marker[data-clickable='true'] { | |
| transition: none; | |
| } | |
| } | |
| </style> | |