opencs2-dataset-viewer / src /lib /components /map-preview.svelte
blanchon's picture
Apply Tailwind v4 shorthand fixes; narrow eslint disables to real FPs
0a96402
raw
history blame
7.81 kB
<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>