Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
81485a2
feature: nades library and practice ui
lukepolo Aug 19, 2026
1b30c23
wip
lukepolo Aug 19, 2026
d84cf6c
bug: escape invalid i18n placeholder in nade import
lukepolo Aug 19, 2026
4314247
server type picker: ranked, practice, valve, custom presets
lukepolo Aug 19, 2026
8ead9a2
pick a dedicated practice server when starting a session
lukepolo Aug 19, 2026
86af35f
rename nades to utility; squash utility migrations
lukepolo Aug 19, 2026
7277dd1
surface practice server load failures; keep unknown server types intact
lukepolo Aug 19, 2026
41fb1b3
show busy practice servers and their holder
lukepolo Aug 19, 2026
ba39a30
restore a live practice session after a refresh; timer settings
lukepolo Aug 20, 2026
06cb480
practice reservation in the top nav
lukepolo Aug 20, 2026
ca45483
type the practice status filter as its enum
lukepolo Aug 20, 2026
8bf897d
subscribe the practice nav chip
lukepolo Aug 20, 2026
6090cfd
show plugin config for practice servers
lukepolo Aug 20, 2026
99cb995
stop selecting the trajectory storage key
lukepolo Aug 20, 2026
cf8e5fd
wip
lukepolo Aug 20, 2026
3dd1fbe
stop rationing practice sessions
lukepolo Aug 20, 2026
6fe8b8a
archive lineups, and animate the list that holds them
lukepolo Aug 20, 2026
3b4094e
treat double precision as the string hasura sends
lukepolo Aug 20, 2026
e33beb4
count each scope tab
lukepolo Aug 20, 2026
e638498
make the card's vote and save buttons do something
lukepolo Aug 20, 2026
c07d031
group the map views; stop the skeletons jumping
lukepolo Aug 20, 2026
71f2faa
keep the archive icon put; never read reaction state raw
lukepolo Aug 20, 2026
b4ebecb
open lineups in place and flip between them
lukepolo Aug 20, 2026
cd04ddc
fold the meta and author views into the map page
lukepolo Aug 20, 2026
f862fbd
fold the block and executes views into the map page
lukepolo Aug 20, 2026
b3739ee
stop deep-watching lineups on every board update
lukepolo Aug 20, 2026
234b3a1
declare meta state before the tabs that read it
lukepolo Aug 20, 2026
7e888d3
keep archived lineups out of the library and give them a way back
lukepolo Aug 20, 2026
877e1b8
give the map page one navigation and put each filter where it acts
lukepolo Aug 20, 2026
aac6dea
move the view switcher to a rail and open the library to guests
lukepolo Aug 20, 2026
77fbff3
put the map first and float its controls on it
lukepolo Aug 20, 2026
69f2271
drop the layers panel and sit the board key with the chips
lukepolo Aug 20, 2026
bec64c4
let the map name itself and rebuild the scope tabs for a narrow column
lukepolo Aug 20, 2026
c5be3bd
zoom the radar, thin the mined overlay, stack the tabs in the shared …
lukepolo Aug 20, 2026
fb3a25b
light mined spots on hover and keep the board and list pointing at th…
lukepolo Aug 20, 2026
5b3bb24
floor the mined overlay at ten players and let usage drive how solid …
lukepolo Aug 20, 2026
6967a51
write up a mined spot straight into the author with its own numbers
lukepolo Aug 20, 2026
22020d2
wire the write-up seed through the page
lukepolo Aug 20, 2026
a012a13
surface the utility library and events in the community menu
lukepolo Aug 20, 2026
da61b40
give the library its own column in the community menu
lukepolo Aug 20, 2026
46f8e4b
submit a lineup for review and let moderators answer
lukepolo Aug 20, 2026
c3f0905
make the map name the map switcher and retire the index grid
lukepolo Aug 20, 2026
f708f53
stop sending null for a not-null tags column and name the failing field
lukepolo Aug 20, 2026
a175289
re-scan the utility library from settings
lukepolo Aug 20, 2026
1def534
read a lineup origin as the feet it is
lukepolo Aug 20, 2026
1d9a5b9
put the lineup's actual numbers on the lineup page
lukepolo Aug 20, 2026
d525417
let a lineup say how precise it has to be
lukepolo Aug 20, 2026
a4ac85d
wip
lukepolo Aug 21, 2026
02b0b26
wip
lukepolo Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions assets/css/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,23 @@ mark {
opacity: 0.6;
}

/* The utility feature is iconed with the in-game HE artwork instead of a lucide
glyph, so the nav entry reads as the same thing as the utility markers in 2D
and 3D playback. The file is a single white path, so masking it lets the
silhouette take currentColor the way a lucide icon does. Plain CSS because
Tailwind will not generate the -webkit- half of the mask pair from arbitrary
values, and Safari still needs it. */
.he-grenade-mask {
mask-image: url("/img/equipment/hegrenade.svg");
-webkit-mask-image: url("/img/equipment/hegrenade.svg");
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
}

/* Faint HUD scanlines laid over event banner media, so photography/video
reads as composed into the tactical chrome instead of pasted above it. */
.tac-scanlines {
Expand Down Expand Up @@ -747,3 +764,62 @@ mark {
color: hsl(var(--tac-amber));
background: hsl(var(--tac-amber) / 0.12);
}

/* The utility board's overlays are bounded by the board, not by the viewport:
collapsing the left nav or opening the right hub narrows it on a desktop, so
a viewport breakpoint fires at the wrong times. Written here rather than as a
Tailwind arbitrary variant because no container-query plugin is installed --
same reason .tac-chip and .tac-amber-cta live here. */
.utility-board {
container-type: inline-size;
container-name: utility-board;
}

/* The view strip collapses to its icons before it can start eating the map's
name. Each tab keeps its icon, its count and its tooltip, and the strip still
scrolls -- so nothing is lost, it just stops competing for the same row. */
@container utility-board (max-width: 44rem) {
.utility-board-tabs .af-label {
display: none;
}
}

/* The caption explaining the markers is the next thing worth losing: the chips
under it carry the same colours and say the same thing in less room. */
@container utility-board (max-width: 34rem) {
.utility-board-key {
display: none;
}
}

/* Then the overlay button drops its label and keeps its icon and its count. */
@container utility-board (max-width: 26rem) {
.utility-board-overlay-label {
display: none;
}
}

/* A field that stays out of the way until you go near it. An authoring panel
that is mostly *reading* -- an execute you are checking, a name you are not
changing -- turns into a wall of boxes when every value wears a border and a
label. The value is the interface; the box appears on hover or focus to say
it can be edited. Lives here rather than in a .ts class map because Tailwind
generates no CSS for arbitrary variants defined outside a scanned template
(same reason .tac-chip and .tac-amber-cta are here). */
.tac-quiet-field {
border-color: transparent;
background-color: transparent;
transition:
border-color 150ms ease,
background-color 150ms ease;
}
.tac-quiet-field:hover:not(:disabled),
.tac-quiet-field:focus,
.tac-quiet-field:focus-visible,
.tac-quiet-field:focus-within {
border-color: hsl(var(--border));
background-color: hsl(var(--muted) / 0.3);
}
.tac-quiet-field:disabled {
opacity: 1;
}
74 changes: 60 additions & 14 deletions components/common/AnimatedFilters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,24 @@ const props = defineProps<{
size?: "lg";
block?: boolean;
fill?: boolean;
// Count over label, in equal columns. For narrow columns where a row of
// label-plus-count pills does not fit and wrapping strands the last one.
stacked?: boolean;
}>();

const model = defineModel<string>();

const containerShape = computed(() =>
props.square ? "rounded-md" : "rounded-full",
props.square || props.stacked ? "rounded-md" : "rounded-full",
);
const indicatorShape = computed(() =>
props.square ? "rounded" : "rounded-full",
props.square || props.stacked ? "rounded" : "rounded-full",
);
const buttonShape = computed(() => {
const base = props.block ? "flex-1" : "";
const base = props.block ? "min-w-0 flex-1" : "";
if (props.stacked) {
return "flex min-w-0 flex-col items-center justify-center gap-1 rounded px-1 py-1.5";
}
if (props.size === "lg") {
return `inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-2.5 font-mono text-[0.72rem] font-bold uppercase leading-tight tracking-[0.08em] ${base}`;
}
Expand All @@ -46,6 +52,13 @@ const buttonShape = computed(() => {
? `inline-flex h-[1.375rem] items-center justify-center gap-1.5 rounded px-2.5 font-mono text-[0.65rem] font-semibold uppercase leading-none tracking-[0.12em] ${base}`
: `inline-flex items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-xs tracking-[0.06em] ${base}`;
});
function countTone(opt: FilterOption) {
if (model.value === opt.key || opt.disabled) {
return "";
}
return (opt.count ?? 0) === 0 ? "opacity-35" : "";
}

function buttonState(opt: FilterOption) {
const selected = model.value === opt.key;
if (opt.disabled) {
Expand Down Expand Up @@ -119,7 +132,11 @@ watch(
:class="[
containerShape,
fill ? 'flex-1 self-stretch' : 'self-start',
block ? 'flex w-full' : 'inline-flex w-fit flex-wrap',
stacked
? 'grid w-full auto-cols-fr grid-flow-col'
: block
? 'flex w-full'
: 'inline-flex w-fit flex-wrap',
]"
>
<span
Expand Down Expand Up @@ -155,11 +172,27 @@ watch(
:class="[buttonShape, buttonState(opt)]"
@click="!opt.disabled && (model = opt.key)"
>
<component :is="opt.icon" v-if="opt.icon" class="h-4 w-4" />
{{ opt.label }}
<span v-if="opt.count !== undefined" class="ml-1 opacity-60">{{
opt.count
}}</span>
<template v-if="stacked">
<span
class="text-[0.95rem] font-semibold leading-none tabular-nums tracking-tight"
:class="countTone(opt)"
>
{{ opt.count ?? 0 }}
</span>
<span class="font-mono text-[0.5rem] uppercase leading-none tracking-[0.08em] opacity-80">
{{ opt.label }}
</span>
</template>
<template v-else>
<component :is="opt.icon" v-if="opt.icon" class="h-4 w-4" />
<!-- Wrapped so a caller can collapse the strip to its icons when its
container runs out of room: a bare text node cannot be hidden,
and as a flex item a span sits exactly where it did. -->
<span v-if="opt.label" class="af-label">{{ opt.label }}</span>
<span v-if="opt.count !== undefined" class="ml-1 opacity-60">{{
opt.count
}}</span>
</template>
</button>
</template>
<div class="max-w-[220px] space-y-0.5">
Expand All @@ -186,11 +219,24 @@ watch(
:class="[buttonShape, buttonState(opt)]"
@click="!opt.disabled && (model = opt.key)"
>
<component :is="opt.icon" v-if="opt.icon" class="h-4 w-4" />
{{ opt.label }}
<span v-if="opt.count !== undefined" class="ml-1 opacity-60">{{
opt.count
}}</span>
<template v-if="stacked">
<span
class="text-[0.95rem] font-semibold leading-none tabular-nums tracking-tight"
:class="countTone(opt)"
>
{{ opt.count ?? 0 }}
</span>
<span class="font-mono text-[0.5rem] uppercase leading-none tracking-[0.08em] opacity-80">
{{ opt.label }}
</span>
</template>
<template v-else>
<component :is="opt.icon" v-if="opt.icon" class="h-4 w-4" />
<span v-if="opt.label" class="af-label">{{ opt.label }}</span>
<span v-if="opt.count !== undefined" class="ml-1 opacity-60">{{
opt.count
}}</span>
</template>
</button>
</template>
</div>
Expand Down
6 changes: 6 additions & 0 deletions components/icons/HeGrenadeIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<span
aria-hidden="true"
class="he-grenade-mask inline-block shrink-0 bg-current"
/>
</template>
23 changes: 23 additions & 0 deletions components/icons/UtilityDriftIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<template>
<!-- The drift tools are still the utility feature, so this is the same HE
artwork with a thrown-arc sweeping into it. Two elements rather than one
inline SVG: the grenade is the shared masked artwork, the arc is drawn
over it. The arc is solid and the grenade stays large on purpose — a
dashed trail or a second landing marker turns to mush at the 16px the
sidebar renders this at. -->
<span aria-hidden="true" class="relative inline-block shrink-0">
<span
class="he-grenade-mask absolute left-[45%] top-[2.5%] h-[69.3%] w-[52.5%] bg-current"
/>
<svg
class="absolute inset-0 h-full w-full"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<path d="M1.2 15C1.7 12 3.4 10.2 6.6 9.4" />
</svg>
</span>
</template>
2 changes: 1 addition & 1 deletion components/match/DesktopSnapshot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Monitor } from "lucide-vue-next";
import socket from "~/web-sockets/Socket";

type SnapshotKind = "live" | "demo" | "bake" | "clips";
type SnapshotKind = "live" | "demo" | "bake" | "clips" | "nades";

const props = withDefaults(
defineProps<{
Expand Down
90 changes: 13 additions & 77 deletions components/match/MatchMapAnalysis.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,10 @@ import {
import { matchHeatmapQuery } from "~/graphql/matchHeatmapGraphql";
import { matchMovementMapQuery } from "~/graphql/matchMovementPathsGraphql";
import RoundSelector from "~/components/match/RoundSelector.vue";

type MapSplit = {
bounds: { top: number; bottom: number };
offset: { x: number; y: number };
};
type RadarMeta = {
resolution: number;
offset: { x: number; y: number };
splits?: MapSplit[];
};
import {
RADAR_CANVAS,
useRadarProjection,
} from "~/composables/useRadarProjection";

type DotCategory = "kills" | "deaths" | "utility" | "util_damage";

Expand Down Expand Up @@ -86,8 +80,7 @@ const { t } = useI18n();
const side = useMatchSide();
const { client } = useApolloClient();

const CANVAS = 1024;
const RADAR_PX = 1024;
const CANVAS = RADAR_CANVAS;

const KILL_COLOR = "#fbbf24";
const DEATH_COLOR = "rgb(239, 68, 68)";
Expand Down Expand Up @@ -117,7 +110,6 @@ const WINDOW_OPTIONS = [10, 20, 30, 45];
const mode = ref<"heatmap" | "movement">("heatmap");
const renderStyle = ref<"heat" | "dots">("dots");

const calibrations = ref<Record<string, RadarMeta> | null>(null);
const radarFailed = ref(false);
const selectedMapId = ref<string | null>(null);

Expand Down Expand Up @@ -237,30 +229,13 @@ function open3dPlayback() {
}
}

const normalizedMap = computed(() =>
(activeMatchMap.value?.map?.name || "")
.trim()
.toLowerCase()
.replace(/_night$/, ""),
);

const calibration = computed<RadarMeta | null>(() => {
if (!calibrations.value || !normalizedMap.value) {
return null;
}
return calibrations.value[normalizedMap.value] ?? null;
});

const radarSrc = computed(() => {
if (!calibration.value || !normalizedMap.value || radarFailed.value) {
return null;
}
return `/radars/${normalizedMap.value}.png`;
});

const has2dRadar = computed(() =>
calibrations.value === null ? true : !!calibration.value,
);
const {
normalizedMap,
calibration,
radarSrc,
hasCalibration: has2dRadar,
projectCalibrated,
} = useRadarProjection(() => activeMatchMap.value?.map?.name, { radarFailed });

const has3dMesh = ref(true);
watch(
Expand All @@ -281,19 +256,6 @@ watch(
{ immediate: true },
);

onMounted(async () => {
try {
const res = await fetch("/radars/metadata.json");
if (res.ok) {
const data = await res.json();
const { _comment, ...rest } = data;
calibrations.value = rest as Record<string, RadarMeta>;
}
} catch {
/* */
}
});

async function loadHeatmap() {
const matchId = props.match?.id;
if (!matchId || matchId === loadedHeatmapMatchId.value) {
Expand Down Expand Up @@ -403,33 +365,7 @@ function parseCoords(
return { x: parts[0], y: parts[1], z: parts[2] ?? 0 };
}

function applySplit(z: number, splits: MapSplit[] | undefined) {
if (!splits) {
return { dx: 0, dy: 0 };
}
for (const s of splits) {
if (z > s.bounds.bottom && z < s.bounds.top) {
return { dx: s.offset.x, dy: s.offset.y };
}
}
return { dx: 0, dy: 0 };
}

function projectRaw(p: { x: number; y: number; z?: number }) {
if (!calibration.value) {
return null;
}
const { resolution, offset, splits } = calibration.value;
const split = applySplit(p.z ?? 0, splits);
const gameX = p.x + offset.x;
const gameY = p.y + offset.y;
const pxX = gameX / resolution + (split.dx / 100) * RADAR_PX;
const pxYFromBottom = gameY / resolution + (split.dy / 100) * RADAR_PX;
return {
x: pxX * (CANVAS / RADAR_PX),
y: CANVAS - pxYFromBottom * (CANVAS / RADAR_PX),
};
}
const projectRaw = projectCalibrated;

const lineupBySteamId = computed(() => {
const out = new Map<string, "lineup_1" | "lineup_2">();
Expand Down
Loading
Loading