Initial commit: TSS Bot Web Frontend (React/Vite + production server)

This commit is contained in:
clxud
2026-07-02 02:09:34 +00:00
commit 36092f0269
87 changed files with 34597 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useRef } from "react";
import { TRUNK_TOP_CSS } from "./Tree";
interface Leaf {
x: number;
y: number;
size: number;
speed: number;
drift: number;
rotation: number;
rotationSpeed: number;
opacity: number;
sway: number;
swaySpeed: number;
phase: number;
}
export default function FallingLeaves({ treeRef }: { treeRef: React.RefObject<HTMLCanvasElement | null> }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
let animId: number;
const leaves: Leaf[] = [];
const MAX_LEAVES = 36;
const frameInterval = 1000 / 30;
let lastFrame = 0;
let isVisible = true;
let isRunning = false;
let treeBounds = { left: 0, top: 0, width: 0 };
let canvasBounds = { left: 0, top: 0 };
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function measure() {
const parent = canvas.parentElement;
const rect = parent?.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(rect?.width || window.innerWidth));
canvas.height = Math.max(1, Math.round(rect?.height || window.innerHeight));
const treeRect = treeRef.current?.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
treeBounds = treeRect
? { left: treeRect.left, top: treeRect.top, width: treeRect.width }
: { left: canvasRect.left + canvas.width * 0.2, top: canvasRect.top + canvas.height * 0.4, width: canvas.width * 0.6 };
canvasBounds = { left: canvasRect.left, top: canvasRect.top };
}
measure();
const resizeObserver = new ResizeObserver(measure);
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
if (treeRef.current) resizeObserver.observe(treeRef.current);
function spawnLeaf(): Leaf {
const spawnY = treeBounds.top - canvasBounds.top + TRUNK_TOP_CSS;
const spawnX = treeBounds.left - canvasBounds.left + Math.random() * treeBounds.width * 0.8 + treeBounds.width * 0.1;
return {
x: spawnX,
y: spawnY,
size: Math.random() * 4 + 2,
speed: Math.random() * 0.3 + 0.15,
drift: Math.random() * 0.5 - 0.25,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.02,
opacity: Math.random() * 0.25 + 0.08,
sway: Math.random() * 30 + 15,
swaySpeed: Math.random() * 0.008 + 0.003,
phase: Math.random() * Math.PI * 2,
};
}
let spawnTimer = 0;
function draw(timestamp: number) {
if (!isRunning) return;
animId = requestAnimationFrame(draw);
if (timestamp - lastFrame < frameInterval) return;
lastFrame = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
spawnTimer++;
if (spawnTimer > 30 && leaves.length < MAX_LEAVES) {
leaves.push(spawnLeaf());
spawnTimer = 0;
}
for (let i = leaves.length - 1; i >= 0; i--) {
const l = leaves[i];
l.y += l.speed;
l.x += l.drift + Math.sin(l.phase) * l.swaySpeed * l.sway * 0.05;
l.phase += l.swaySpeed;
l.rotation += l.rotationSpeed;
ctx.save();
ctx.translate(l.x, l.y);
ctx.rotate(l.rotation);
ctx.globalAlpha = l.opacity;
ctx.fillStyle = "#fdb068";
const s = Math.round(l.size);
ctx.fillRect(-s / 2, -s / 2, s, s);
ctx.restore();
if (l.y > canvas.height + 10) {
leaves.splice(i, 1);
}
}
}
function start() {
if (reducedMotion || isRunning || !isVisible || document.hidden) return;
isRunning = true;
animId = requestAnimationFrame(draw);
}
function stop() {
isRunning = false;
if (animId) cancelAnimationFrame(animId);
}
const intersectionObserver = new IntersectionObserver(([entry]) => {
isVisible = entry.isIntersecting;
if (isVisible) start();
else stop();
});
const onVisibilityChange = () => {
if (document.hidden) stop();
else start();
};
intersectionObserver.observe(canvas);
document.addEventListener("visibilitychange", onVisibilityChange);
start();
return () => {
stop();
intersectionObserver.disconnect();
resizeObserver.disconnect();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);
return <canvas ref={canvasRef} className="falling-leaves" />;
}
+324
View File
@@ -0,0 +1,324 @@
import React, { forwardRef, useEffect, useRef } from "react";
const PX = 2;
const W = 500;
const H = 420;
const LEAF_FLOOR = H - 60;
function buildTree(seed: number) {
const woodGrid = new Uint8Array(W * H);
const leafGrid = new Uint8Array(W * H);
let s = seed;
function rand() {
s = (s * 16807 + 0) % 2147483647;
return (s & 0x7fffffff) / 0x7fffffff;
}
function setWood(x: number, y: number, shade: number) {
const px = Math.round(x), py = Math.round(y);
if (px >= 0 && px < W && py >= 0 && py < H) {
const idx = py * W + px;
if (woodGrid[idx] === 0) woodGrid[idx] = shade;
}
}
function setLeaf(x: number, y: number, shade: number) {
const px = Math.round(x), py = Math.round(y);
if (px >= 0 && px < W && py >= 0 && py < LEAF_FLOOR) {
const idx = py * W + px;
if (shade > leafGrid[idx]) leafGrid[idx] = shade;
}
}
function drawLeaf(x: number, y: number, shade: number) {
const px = Math.round(x), py = Math.round(y);
setLeaf(px, py, shade);
const r = rand();
if (r < 0.60) setLeaf(px + (rand() > 0.5 ? 1 : -1), py, shade);
if (r < 0.32) setLeaf(px, py + (rand() > 0.45 ? -1 : 1), shade);
}
function leafScatter(cx: number, cy: number, radius: number, count: number) {
for (let i = 0; i < count; i++) {
const a = rand() * Math.PI * 2;
const r = Math.sqrt(rand()) * radius;
const lx = cx + Math.cos(a) * r * 0.5;
const vertStretch = 1.0 + (cy / H) * 3.0;
const ly = cy + Math.abs(Math.sin(a)) * r * vertStretch;
const yFr = (ly - cy) / radius;
const shade = yFr < 0.5 ? 3 : yFr < 1.2 ? 2 : 1;
drawLeaf(lx, ly, shade);
}
}
function drawLine(
x0: number, y0: number, x1: number, y1: number,
thickness: number, shade: number,
) {
const dx = x1 - x0, dy = y1 - y0;
const dist = Math.sqrt(dx * dx + dy * dy);
const steps = Math.max(Math.ceil(dist * 2), 1);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const bx = x0 + dx * t, by = y0 + dy * t;
const r = (thickness / 2) * (1 - t * 0.4);
for (let oy = -Math.ceil(r); oy <= Math.ceil(r); oy++)
for (let ox = -Math.ceil(r); ox <= Math.ceil(r); ox++)
if (ox * ox + oy * oy <= r * r) setWood(bx + ox, by + oy, shade);
}
}
function branch(
x: number, y: number,
angle: number, length: number,
thickness: number, depth: number,
) {
const endX = x + Math.cos(angle) * length;
const endY = y + Math.sin(angle) * length;
const woodShade = thickness > 5 ? 1 : thickness > 2.5 ? 2 : 3;
drawLine(x, y, endX, endY, thickness, woodShade);
if (depth <= 0 || length < 1.5) {
const r = 4 + rand() * 6;
const normalised = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
const deg = normalised * (180 / Math.PI);
const isNearHorizontal = deg < 30 || deg > 150;
if (isNearHorizontal) {
for (let i = 0; i < Math.round(r * r * 0.8); i++) {
const ox = (rand() - 0.5) * r * 2;
const oy = -rand() * r * 2.5;
const shade = oy < -r ? 3 : 2;
drawLeaf(endX + ox, endY + oy, shade);
}
} else {
leafScatter(endX, endY, r, Math.round(r * r * 0.6));
}
return;
}
if (depth === 1) {
const steps = Math.ceil(length / 5);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const mx = x + Math.cos(angle) * length * t;
const my = y + Math.sin(angle) * length * t;
leafScatter(mx, my, 3 + rand() * 2, 8 + Math.round(rand() * 6));
}
}
const numChildren = depth > 4 ? 2 : 3 + (rand() > 0.6 ? 1 : 0);
for (let i = 0; i < numChildren; i++) {
const spread = 0.30 + rand() * 0.20;
const angleOffset = (i - (numChildren - 1) / 2) * spread;
const droopBias = 0;
const childAngle = angle + angleOffset + (rand() - 0.5) * 0.1 + droopBias;
const lenFactor = 0.60 + rand() * 0.12;
const thickFactor = 0.50 + rand() * 0.10;
branch(endX, endY, childAngle, length * lenFactor,
Math.max(1, thickness * thickFactor), depth - 1);
}
if (depth > 1 && rand() > 0.25) {
const t = 0.25 + rand() * 0.35;
const sx = x + Math.cos(angle) * length * t;
const sy = y + Math.sin(angle) * length * t;
const side = rand() > 0.5 ? 1 : -1;
branch(sx, sy,
angle + side * (0.30 + rand() * 0.35), length * 0.5,
Math.max(1, thickness * 0.45), depth - 2);
}
}
const cx = W / 2;
const trunkTop = H - 150;
const base = H - 50;
for (let ty = trunkTop; ty <= base; ty++) {
const t = (ty - trunkTop) / (base - trunkTop);
const width = 10 + t * t * 16;
const hw = Math.ceil(width);
for (let ox = -hw; ox <= hw; ox++) {
const px = Math.round(cx + ox), py = ty;
if (px < 0 || px >= W || py < 0 || py >= H) continue;
woodGrid[py * W + px] = 2;
}
}
branch(cx - 30, trunkTop + 8, -Math.PI * 0.95, 70, 11, 7);
branch(cx - 22, trunkTop + 4, -Math.PI * 0.87, 68, 10, 7);
branch(cx - 14, trunkTop, -Math.PI * 0.78, 66, 10, 7);
branch(cx - 8, trunkTop - 4, -Math.PI * 0.68, 64, 9, 7);
branch(cx - 8, trunkTop + 4, -Math.PI * 0.70, 71, 10, 7);
branch(cx - 4, trunkTop - 7, -Math.PI * 0.60, 62, 9, 7);
branch(cx, trunkTop - 6, -Math.PI * 0.55, 70, 9, 7);
branch(cx, trunkTop - 7, -Math.PI * 0.50, 73, 9, 7);
branch(cx, trunkTop - 7, -Math.PI * 0.55, 77, 9, 7);
branch(cx, trunkTop - 6, -Math.PI * 0.45, 70, 9, 7);
branch(cx, trunkTop - 6, -Math.PI * 0.60, 70, 9, 7);
branch(cx, trunkTop - 6, -Math.PI * 0.40, 70, 9, 7);
branch(cx + 30, trunkTop + 8, -Math.PI * 0.1, 65, 11, 7);
branch(cx + 22, trunkTop + 4, -Math.PI * 0.18, 65, 10, 7);
branch(cx + 14, trunkTop, -Math.PI * 0.22, 63, 10, 7);
branch(cx + 14, trunkTop, -Math.PI * 0.25, 65, 10, 7);
branch(cx + 8, trunkTop - 4, -Math.PI * 0.32, 61, 9, 7);
branch(cx + 4, trunkTop - 7, -Math.PI * 0.40, 62, 9, 7);
branch(cx + 2, trunkTop + 4, -Math.PI * 0.45, 67, 10, 7);
branch(cx + 2, trunkTop + 5, -Math.PI * 0.17, 67, 10, 7);
branch(cx - 10, trunkTop + 20, -Math.PI * 0.75, 45, 7, 6);
branch(cx - 5, trunkTop + 20, -Math.PI * 0.62, 42, 6, 6);
branch(cx, trunkTop + 18, -Math.PI * 0.50, 44, 7, 6);
branch(cx + 10, trunkTop + 20, -Math.PI * 0.25, 45, 7, 6);
branch(cx + 5, trunkTop + 20, -Math.PI * 0.38, 42, 6, 6);
branch(cx + 3, trunkTop - 6, -Math.PI * 0.68, 10, 9, 7);
branch(cx - 3, trunkTop - 7, -Math.PI * 0.32, 10, 9, 7);
branch(cx + 3, trunkTop - 6, -Math.PI * 0.7, 5, 9, 7);
branch(cx - 3, trunkTop - 7, -Math.PI * 0.3, 5, 9, 7);
branch(cx + 3, trunkTop - 6, -Math.PI * 0.73, 20, 9, 7);
branch(cx, trunkTop - 3, -Math.PI * 0.15, 90, 5, 3);
return { woodGrid, leafGrid };
}
const WOOD_COLORS = ['', '#c96303', '#c96303', '#c96303', '#321901'];
const LEAF_COLORS = ['', '#ed5145', '#fdb068', '#fee5cd'];
const GROUND_COLORS = ['#fee5cd', '#fdca9b', '#fdb068'];
const GRASS_COLORS = ['#ed5145', '#fb7b04', '#c96303', '#f17c74'];
function hexToRgba(hex: string) {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
255,
];
}
const COLOR_RGBA = new Map(
[...WOOD_COLORS, ...LEAF_COLORS, ...GROUND_COLORS, ...GRASS_COLORS]
.filter(Boolean)
.map((color) => [color, hexToRgba(color)]),
);
export const TRUNK_TOP_CSS = (H - 150) + 10;
let cachedTreeCanvas: HTMLCanvasElement | null = null;
function renderTreeCanvas() {
if (cachedTreeCanvas) return cachedTreeCanvas;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
canvas.width = W * PX;
canvas.height = H * PX;
ctx.imageSmoothingEnabled = false;
const { woodGrid, leafGrid } = buildTree(55);
const pixelCanvas = document.createElement("canvas");
const pixelCtx = pixelCanvas.getContext("2d")!;
pixelCanvas.width = W;
pixelCanvas.height = H;
const image = pixelCtx.createImageData(W, H);
function setPixel(x: number, y: number, color: string) {
const rgba = COLOR_RGBA.get(color)!;
const index = (y * W + x) * 4;
image.data[index] = rgba[0];
image.data[index + 1] = rgba[1];
image.data[index + 2] = rgba[2];
image.data[index + 3] = rgba[3];
}
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const v = woodGrid[y * W + x];
if (v) setPixel(x, y, WOOD_COLORS[v] ?? '#321901');
}
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const v = leafGrid[y * W + x];
if (v) setPixel(x, y, LEAF_COLORS[v]);
}
const GROUND_Y = H - 50;
let gs = 12345;
function grassRand() {
gs = (gs * 16807 + 0) % 2147483647;
return (gs & 0x7fffffff) / 0x7fffffff;
}
for (let x = 0; x < W; x++) {
const wave = Math.sin(x * 0.05) * 3 + Math.sin(x * 0.12) * 2;
const groundTop = Math.round(GROUND_Y + wave);
for (let y = groundTop; y < H; y++) {
const depth = (y - groundTop) / (H - groundTop);
const ci = depth < 0.3 ? 0 : depth < 0.7 ? 1 : 2;
setPixel(x, y, GROUND_COLORS[ci]);
}
}
for (let x = 0; x < W; x++) {
if (grassRand() > 0.35) continue;
const wave = Math.sin(x * 0.05) * 3 + Math.sin(x * 0.12) * 2;
const surfaceY = Math.round(GROUND_Y + wave);
const bladeH = Math.floor(grassRand() * 4) + 2;
const color = GRASS_COLORS[Math.floor(grassRand() * GRASS_COLORS.length)];
for (let b = 0; b < bladeH; b++) {
setPixel(x, surfaceY - b - 1, color);
}
if (grassRand() > 0.6 && x + 1 < W) {
setPixel(x + 1, surfaceY - 1, color);
}
}
pixelCtx.putImageData(image, 0, 0);
ctx.drawImage(pixelCanvas, 0, 0, canvas.width, canvas.height);
cachedTreeCanvas = canvas;
return canvas;
}
export function prewarmTreeCanvas() {
renderTreeCanvas();
}
const Tree = forwardRef<HTMLCanvasElement>(function Tree(_, ref) {
const internalRef = useRef<HTMLCanvasElement>(null);
const canvasRef = (ref as React.RefObject<HTMLCanvasElement>) ?? internalRef;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
const draw = () => {
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = W * PX;
canvas.height = H * PX;
ctx.drawImage(renderTreeCanvas(), 0, 0);
};
const requestIdle = window.requestIdleCallback ?? ((callback) => window.setTimeout(callback, 100));
const cancelIdle = window.cancelIdleCallback ?? window.clearTimeout;
const idleId = requestIdle(draw, { timeout: 700 });
return () => cancelIdle(idleId);
}, []);
return (
<canvas
ref={canvasRef}
className="tree"
style={{ imageRendering: "pixelated" }}
/>
);
});
export default Tree;
+150
View File
@@ -0,0 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<title>__SEO_TITLE__</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#e82517" />
<meta name="application-name" content="Toothless' TSS Bot" />
<style>
html { background: #130d08; }
html[data-theme="dark"] { background: #130d08; color-scheme: dark; }
html[data-theme="dark"] body,
html[data-theme="dark"] #root { background: #130d08; }
html[data-theme="light"] { background: #fefde7; color-scheme: light; }
html[data-theme="light"] body,
html[data-theme="light"] #root { background: #fefde7; }
</style>
<script>
(() => {
const cookies = Object.fromEntries(
document.cookie.split('; ').filter(Boolean).map((item) => {
const separator = item.indexOf('=')
const key = separator === -1 ? item : item.slice(0, separator)
const value = separator === -1 ? '' : item.slice(separator + 1)
try {
return [decodeURIComponent(key), decodeURIComponent(value)]
} catch {
return [key, value]
}
}),
)
let theme = cookies['tssbot_theme']
if (!theme) {
try {
theme = localStorage.getItem('tssbot.theme')
} catch {}
}
theme = theme === 'light' ? 'light' : 'dark'
const bg = theme === 'dark' ? '#130d08' : '#fefde7'
document.documentElement.dataset.theme = theme
document.documentElement.style.backgroundColor = bg
document.documentElement.style.colorScheme = theme
const customColor = cookies['tssbot_custom_color'] || localStorage.getItem('tssbot.customColor')
if (customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)) {
document.documentElement.style.setProperty('--color-fury-cyan', customColor)
document.documentElement.style.setProperty('--color-ring', customColor)
const bigint = parseInt(customColor.replace('#', ''), 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255
document.documentElement.style.setProperty('--color-shadow', 'rgba(' + r + ', ' + g + ', ' + b + ', ' + (theme === 'dark' ? '0.18' : '0.12') + ')')
const adjust = (val, percent) => percent > 0
? Math.max(0, Math.min(255, Math.round(val + (255 - val) * percent)))
: Math.max(0, Math.min(255, Math.round(val * (1 + percent))))
const formatHex = (nr, ng, nb) => '#' + ((1 << 24) + (nr << 16) + (ng << 8) + nb).toString(16).slice(1)
const aquaColor = formatHex(adjust(r, 0.2), adjust(g, 0.2), adjust(b, 0.2))
const violetColor = formatHex(adjust(r, -0.2), adjust(g, -0.2), adjust(b, -0.2))
document.documentElement.style.setProperty('--color-fury-aqua', aquaColor)
document.documentElement.style.setProperty('--color-fury-violet', violetColor)
}
document.querySelector('meta[name="theme-color"]')?.setAttribute(
'content',
customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)
? customColor
: (theme === 'dark' ? '#101211' : '#e82517')
)
try {
const rawColors = cookies['tssbot_custom_colors'] || localStorage.getItem('tssbot.customColors')
if (rawColors) {
const colors = JSON.parse(rawColors)
const cssVarMap = { bg: '--color-bg', surface: '--color-surface', text: '--color-text', border: '--color-border', textSoft: '--color-text-soft', textMuted: '--color-text-muted' }
for (const [field, cssVar] of Object.entries(cssVarMap)) {
if (colors[field] && /^#[0-9a-fA-F]{6}$/.test(colors[field])) {
document.documentElement.style.setProperty(cssVar, colors[field])
if (field === 'bg') document.documentElement.style.backgroundColor = colors[field]
}
}
}
} catch {}
let analyticsPreferences = null
const serializedPreferences = cookies['tssbot_analytics_preferences']
try {
analyticsPreferences = JSON.parse(
serializedPreferences || localStorage.getItem('tssbot.analyticsPreferences') || 'null',
)
} catch {}
window.__TSS_BOOT_PREFERENCES__ = { analyticsPreferences, theme }
})()
</script>
<meta name="tss-turnstile-session" content="__TURNSTILE_SESSION__" />
<meta
name="description"
content="__SEO_DESCRIPTION__"
/>
<meta
name="keywords"
content="War Thunder TSS, Tournament Service, TSS leaderboard, TSS teams, TSS battle logs, War Thunder tournaments, Toothless TSS Bot"
/>
<meta name="author" content="Toothless' TSS Bot" />
<meta name="robots" content="__SEO_ROBOTS__" />
<link rel="canonical" href="__SEO_CANONICAL__" />
<meta property="og:site_name" content="Toothless' TSS Bot" />
<meta property="og:locale" content="en_GB" />
<meta property="og:type" content="__SEO_OG_TYPE__" />
<meta property="og:url" content="__SEO_CANONICAL__" />
<meta property="og:title" content="__SEO_TITLE__" />
<meta
property="og:description"
content="__SEO_DESCRIPTION__"
/>
<meta property="og:image" content="__SEO_IMAGE__" />
<meta property="og:image:secure_url" content="__SEO_IMAGE__" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Toothless' TSS Bot War Thunder TSS dashboard" />
<meta property="article:author" content="__SEO_AUTHOR__" />
<meta property="article:published_time" content="__SEO_PUBLISHED_TIME__" />
<meta property="article:modified_time" content="__SEO_MODIFIED_TIME__" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="__SEO_TITLE__" />
<meta
name="twitter:description"
content="__SEO_DESCRIPTION__"
/>
<meta name="twitter:image" content="__SEO_IMAGE__" />
<link rel="icon" type="image/svg+xml" href="/embed-icon.svg" />
<link rel="apple-touch-icon" href="/embed-icon.svg" />
<script type="application/ld+json" id="site-structured-data">
__SEO_JSON_LD__
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
# Static Public Data
The web server serves `/data/*` from the same public data cache used by
`/api/tss/*`, and fills missing snapshots from the matching API route with a
short timeout. The frontend uses `/api/tss/*` by default; set
`VITE_STATIC_DATA=true` only for static-first experiments.
- `/data/leaderboard-teams.json`
- `/data/leaderboard-players.json`
- `/data/home-teams.json`
- `/data/recent-games.json`
- `/data/teams/{key}.json`
- `/data/teams/{key}.games.json`
- `/data/players/{key}.json`
- `/data/games/{key}.json`
- `/data/games/{key}.logs.json`
`{key}` is `encodeURIComponent(value).replace(/%/g, '~')`.
Snapshot files should keep the same response shape as the matching `/api/tss/*`
endpoint. Missing files are fine: the app remembers the miss for the current
browser session and uses the existing API route instead.
Do not generate every possible entity forever. Keep `/data` bounded:
- Always generate the small global snapshots: leaderboards, home teams, recent
games.
- Generate team/player/game detail snapshots only for hot items, such as current
leaderboard entries, recent games, recently viewed pages, or pinned items.
- Prune detail snapshots by age and access. For example: keep recent games for
7-30 days, keep leaderboard teams/players while they remain ranked, and delete
cold files that have not been refreshed recently.
- Write snapshots atomically (`file.tmp` then rename) so readers never see a
partial JSON file.
- Serve compressed responses from the web server/CDN when possible. Keep source
JSON minified unless humans need to inspect a fixture.
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-labelledby="title">
<title id="title">Toothless' TSS Bot tree icon</title>
<rect width="96" height="96" rx="18" fill="#fefde7"/>
<rect x="7" y="7" width="82" height="82" rx="14" fill="#fff2e6" stroke="#fdca9b" stroke-width="4"/>
<g shape-rendering="crispEdges">
<rect x="43" y="48" width="10" height="26" fill="#c96303"/>
<rect x="39" y="62" width="18" height="10" fill="#c96303"/>
<rect x="34" y="70" width="28" height="5" fill="#c96303"/>
<rect x="18" y="28" width="28" height="14" fill="#ed5145"/>
<rect x="28" y="18" width="28" height="15" fill="#ed5145"/>
<rect x="48" y="24" width="30" height="16" fill="#ed5145"/>
<rect x="28" y="40" width="40" height="16" fill="#ed5145"/>
<rect x="24" y="32" width="18" height="6" fill="#fdb068"/>
<rect x="34" y="22" width="18" height="6" fill="#fdb068"/>
<rect x="54" y="28" width="18" height="6" fill="#fdb068"/>
<rect x="38" y="44" width="20" height="6" fill="#fdb068"/>
<rect x="40" y="25" width="8" height="4" fill="#fee5cd"/>
<rect x="56" y="31" width="8" height="4" fill="#fee5cd"/>
<rect x="46" y="47" width="8" height="4" fill="#fee5cd"/>
<rect x="14" y="76" width="68" height="6" fill="#fdca9b"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+80
View File
@@ -0,0 +1,80 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title description">
<title id="title">Toothless' TSS Bot</title>
<desc id="description">A share card for Toothless' TSS Bot using the pixel tree from the home page.</desc>
<rect width="1200" height="630" fill="#fefde7"/>
<path d="M0 452 120 384l118 42 152-126 168 112 132-76 124 84 174-154 212 176v188H0Z" fill="#fcfbcf"/>
<path d="M0 498 130 430l110 42 142-92 170 102 120-54 132 66 180-112 216 128v120H0Z" fill="#f9f69f"/>
<rect x="72" y="72" width="1056" height="486" rx="28" fill="#fefde7" stroke="#fdca9b" stroke-width="6"/>
<rect x="96" y="96" width="1008" height="438" rx="18" fill="#fff2e6" opacity=".54"/>
<g transform="translate(780 144) scale(.58)" shape-rendering="crispEdges">
<rect x="0" y="0" width="500" height="420" fill="#fefde7" stroke="#fdca9b" stroke-width="6"/>
<path d="M0 372c55-16 116-12 173 3 46 12 101 20 157 4 58-17 112-10 170 5v36H0Z" fill="#fee5cd"/>
<path d="M0 394c54-10 110-8 172 6 54 12 108 10 160-4 58-15 112-10 168 4v20H0Z" fill="#fdca9b"/>
<g fill="#c96303">
<rect x="232" y="248" width="38" height="118"/>
<rect x="224" y="300" width="54" height="72"/>
<rect x="214" y="346" width="76" height="34"/>
<rect x="198" y="366" width="112" height="18"/>
<rect x="196" y="232" width="62" height="16" transform="rotate(-28 196 232)"/>
<rect x="248" y="222" width="64" height="14" transform="rotate(25 248 222)"/>
<rect x="176" y="198" width="88" height="14" transform="rotate(-46 176 198)"/>
<rect x="242" y="188" width="88" height="14" transform="rotate(42 242 188)"/>
<rect x="150" y="164" width="92" height="12" transform="rotate(-20 150 164)"/>
<rect x="270" y="154" width="98" height="12" transform="rotate(18 270 154)"/>
<rect x="212" y="120" width="82" height="12" transform="rotate(-72 212 120)"/>
<rect x="246" y="118" width="78" height="12" transform="rotate(68 246 118)"/>
</g>
<g fill="#ed5145">
<rect x="76" y="110" width="112" height="52"/>
<rect x="114" y="70" width="128" height="62"/>
<rect x="198" y="48" width="116" height="68"/>
<rect x="278" y="76" width="132" height="60"/>
<rect x="334" y="126" width="92" height="54"/>
<rect x="102" y="160" width="116" height="58"/>
<rect x="190" y="134" width="148" height="66"/>
<rect x="278" y="172" width="116" height="58"/>
</g>
<g fill="#fdb068">
<rect x="98" y="124" width="84" height="26"/>
<rect x="138" y="84" width="92" height="30"/>
<rect x="216" y="66" width="82" height="28"/>
<rect x="292" y="92" width="96" height="30"/>
<rect x="354" y="142" width="58" height="24"/>
<rect x="126" y="178" width="78" height="24"/>
<rect x="210" y="150" width="104" height="28"/>
<rect x="300" y="190" width="76" height="24"/>
</g>
<g fill="#fee5cd">
<rect x="150" y="98" width="44" height="16"/>
<rect x="238" y="76" width="38" height="16"/>
<rect x="314" y="104" width="48" height="16"/>
<rect x="230" y="160" width="48" height="16"/>
<rect x="322" y="202" width="36" height="14"/>
</g>
<g fill="#fb7b04">
<rect x="74" y="360" width="6" height="18"/>
<rect x="116" y="366" width="6" height="16"/>
<rect x="342" y="362" width="6" height="20"/>
<rect x="402" y="370" width="6" height="14"/>
<rect x="444" y="364" width="6" height="18"/>
</g>
</g>
<g transform="translate(122 128)">
<text x="0" y="36" fill="#e82517" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="26" font-weight="800" letter-spacing="2">LIVE TSS INTEL</text>
<text x="0" y="132" fill="#000" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="76" font-weight="900">Toothless'</text>
<text x="0" y="216" fill="#000" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="76" font-weight="900">TSS Bot</text>
<text x="0" y="282" fill="#555" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="30" font-weight="600">Leaderboards, battle logs, uptime, and viewer analytics.</text>
</g>
<g transform="translate(122 458)" fill="#000" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="24" font-weight="800">
<rect x="0" y="-34" width="184" height="54" rx="8" fill="#fcfbcf" stroke="#fdca9b"/>
<text x="24" y="2">Teams</text>
<rect x="208" y="-34" width="214" height="54" rx="8" fill="#fcfbcf" stroke="#fdca9b"/>
<text x="232" y="2">Battle Logs</text>
<rect x="446" y="-34" width="154" height="54" rx="8" fill="#fcfbcf" stroke="#fdca9b"/>
<text x="470" y="2">Uptime</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+12
View File
@@ -0,0 +1,12 @@
# 3ds Max Wavefront OBJ Exporter v0.97b - (c)2007 guruware
# File Created: 27.04.2017 18:23:39
newmtl wire_022022022
Ns 32
d 1
Tr 0
Tf 1 1 1
illum 2
Ka 0.0863 0.0863 0.0863
Kd 0.0863 0.0863 0.0863
Ks 0.3500 0.3500 0.3500
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+6822
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+695
View File
@@ -0,0 +1,695 @@
// Pure 3D replay view. No DOM beyond its own <canvas>; driven entirely by an
// external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine.
// Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of
// its own UI (labels, axes, guide lines, controls, hover/active-list panels).
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
const MODEL_PATH = '/models/t34/'
const MINIMAP_URL = (level) => `/api/match/minimap/${level}`
const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full`
const WIN_COLOR = '#00c800'
const LOSE_COLOR = '#dc1e1e'
const DRONE_COLOR = '#cbd5e1'
const DIRECTION_SAMPLE_MS = 700
const DIRECTION_BLEND = 0.22
const KILL_LINE_WINDOW_MS = 3000
const FLIP_MAP_TEXTURE_Z = true
function coordsAreValid(c) {
return c &&
Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) &&
Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) &&
Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1)
}
function normalizeCoords(c) {
if (!coordsAreValid(c)) return null
return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) }
}
function mapSourceRect(baseCoords, renderCoords) {
const base = normalizeCoords(baseCoords)
const render = normalizeCoords(renderCoords)
if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 }
const dx = base.x1 - base.x0
const dz = base.z1 - base.z0
const xLo = Math.min(render.x0, render.x1)
const xHi = Math.max(render.x0, render.x1)
const zLo = Math.min(render.z0, render.z1)
const zHi = Math.max(render.z0, render.z1)
const u0 = (xLo - base.x0) / dx
const u1 = (xHi - base.x0) / dx
const v0 = (zLo - base.z0) / dz
const v1 = (zHi - base.z0) / dz
const uMin = Math.max(0, Math.min(1, Math.min(u0, u1)))
const uMax = Math.max(0, Math.min(1, Math.max(u0, u1)))
const vMin = Math.max(0, Math.min(1, Math.min(v0, v1)))
const vMax = Math.max(0, Math.min(1, Math.max(v0, v1)))
const w = uMax - uMin
const h = vMax - vMin
if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 }
return { u: uMin, v: 1 - vMax, w, h }
}
function fallbackBoundsCoords(bounds) {
const pad = Math.max(bounds.planarSpan * 0.15, 250)
return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad }
}
function catmullRom(a, b, c, d, t) {
const t2 = t * t
const t3 = t2 * t
return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3)
}
export default class ReplayCanvas3D {
constructor(container, data) {
this.container = container
this.data = data
this.disposed = false
this.currentT = 0
this.selectedPlayerId = null
this.smoothByEntity = new Map()
this.players = {}
for (const p of data.players || []) this.players[p.id] = p
this.teamWon = data.teamWon
this.winnerSlot = Number(data.winnerSlot) || 0
this._buildEntities()
this._buildCaptureModel()
this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos)
this.bounds = this._computeBounds()
this.scale = 1200 / this.bounds.planarSpan
this._initThree()
this._mode = 'ground'
this.mapInfo = this._resolveMapInfo('ground')
this._buildScene()
this._loadTankTemplate()
this._loadMapTexture()
this._animate = this._animate.bind(this)
this._raf = requestAnimationFrame(this._animate)
this.setTime(0)
}
// ---- data adaptation -----------------------------------------------------
_isWinner(entity) {
if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon
return entity.droneTeam === this.teamWon
}
_colorFor(entity) {
if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR
return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR
}
_buildEntities() {
this.entities = []
for (const e of this.data.entities || []) {
if (!Array.isArray(e.path) || !e.path.length) continue
const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) }))
this.entities.push({
playerId: e.playerId,
entityIndex: e.entityIndex,
type: e.type,
droneTeam: e.droneTeam,
path,
startT: path[0].t,
endT: path[path.length - 1].t,
})
}
}
_buildCaptureModel() {
const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : []
const state = this.data.captureState || {}
this.captureZones = areas.map((area, index) => {
const letter = String.fromCharCode(65 + index)
const cap = Array.isArray(state[letter])
? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v))
: []
const center = area.tm?.center || [area.x, 0, area.z]
return {
key: letter,
center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) },
radius: Math.max(1, Number(area.radius) || 1),
cap,
}
})
}
_computeBounds() {
const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
for (const e of this.entities) {
for (const p of e.path) {
b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x)
b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y)
b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z)
}
}
if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 }
b.centerX = (b.minX + b.maxX) / 2
b.centerZ = (b.minZ + b.maxZ) / 2
b.spanX = Math.max(1, b.maxX - b.minX)
b.spanZ = Math.max(1, b.maxZ - b.minZ)
b.planarSpan = Math.max(b.spanX, b.spanZ)
return b
}
_resolveMapInfo(mode) {
const level = this.data.mission?.level
const full = this.data.fullMapLevel
if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) {
const coords = normalizeCoords(this.data.mapCoords)
return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } }
}
const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds)
const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords
return {
image: level ? MINIMAP_URL(level) : null,
coords,
baseCoords,
sourceRect: mapSourceRect(baseCoords, coords),
}
}
// ---- coordinate transforms ----------------------------------------------
_toWorld(p) {
const b = this.bounds
return new THREE.Vector3(
(p.x - b.centerX) * this.scale,
(p.y - b.minY) * this.scale,
(p.z - b.centerZ) * this.scale,
)
}
_renderPoint(p) {
if (!FLIP_MAP_TEXTURE_Z) return p
const coords = normalizeCoords(this.mapInfo?.coords)
if (!coords) return p
return { ...p, z: coords.z0 + coords.z1 - p.z }
}
// ---- three.js setup ------------------------------------------------------
_initThree() {
const canvas = document.createElement('canvas')
canvas.className = 'rc3d-canvas'
this.canvasEl = canvas
this.container.appendChild(canvas)
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
this.renderer.setClearColor(0x0b0d10, 1)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000)
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true
this.controls.dampingFactor = 0.08
this.controls.screenSpacePanning = false
this.controls.maxPolarAngle = Math.PI * 0.49
this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE
this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN
this.root = new THREE.Group()
this.mapGroup = new THREE.Group()
this.pathGroup = new THREE.Group()
this.zoneGroup = new THREE.Group()
this.killLineGroup = new THREE.Group()
this.markerGroup = new THREE.Group()
this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup)
this.scene.add(this.root)
this.scene.add(new THREE.AmbientLight(0xffffff, 0.72))
const sun = new THREE.DirectionalLight(0xffffff, 1.1)
sun.position.set(240, 500, 180)
this.scene.add(sun)
this.resize()
}
_worldMapExtents(coords = this.mapInfo?.coords) {
const c = normalizeCoords(coords)
if (!c) return null
const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 })
const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 })
return {
minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x),
minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z),
width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z),
}
}
_resetCamera() {
const ext = this._worldMapExtents()
const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100)
this.camera.position.set(span * 0.45, span * 0.55, span * 0.72)
this.controls.target.set(0, 0, 0)
this.controls.minDistance = Math.max(18, span * 0.025)
this.controls.maxDistance = Math.max(320, span * 1.8)
this.camera.far = Math.max(9000, span * 8)
this.camera.updateProjectionMatrix()
this.controls.update()
}
// ---- scene construction --------------------------------------------------
_buildScene() {
this._clearGroup(this.mapGroup)
this._clearGroup(this.pathGroup)
this._clearGroup(this.markerGroup)
this._clearGroup(this.zoneGroup)
this._clearGroup(this.killLineGroup)
this._buildMapPlane()
this._buildCaptureZones()
this._buildKillLines()
this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e))
this._applyHighlight()
this._resetCamera()
}
_clearGroup(group) {
for (const child of [...group.children]) {
group.remove(child)
child.traverse?.((c) => {
if (c.geometry) c.geometry.dispose()
if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose())
})
}
}
_buildMapPlane() {
const info = this.mapInfo
if (!info || !coordsAreValid(info.coords)) return
const x0 = Math.min(info.coords.x0, info.coords.x1)
const x1 = Math.max(info.coords.x0, info.coords.x1)
const z0 = Math.min(info.coords.z0, info.coords.z1)
const z1 = Math.max(info.coords.z0, info.coords.z1)
const width = Math.max(1, (x1 - x0) * this.scale)
const depth = Math.max(1, (z1 - z0) * this.scale)
const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 })
const geometry = new THREE.PlaneGeometry(width, depth, 1, 1)
this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 })
geometry.rotateX(-Math.PI / 2)
const material = new THREE.MeshBasicMaterial({
color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide,
})
const plane = new THREE.Mesh(geometry, material)
plane.position.set(center.x, -0.12, center.z)
plane.renderOrder = -10
this.mapGroup.add(plane)
this._mapMaterial = material
}
_applyPlaneUvs(geometry, rect) {
const u0 = rect.u
const u1 = rect.u + rect.w
const vTop = rect.v
const vBottom = rect.v + rect.h
const uv = geometry.attributes.uv
if (FLIP_MAP_TEXTURE_Z) {
uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop)
uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom)
} else {
uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom)
uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop)
}
uv.needsUpdate = true
}
_loadMapTexture() {
if (!this.mapInfo.image || !this._mapMaterial) return
const material = this._mapMaterial
new THREE.TextureLoader().load(
this.mapInfo.image,
(texture) => {
if (this.disposed) { texture.dispose(); return }
texture.colorSpace = THREE.SRGBColorSpace
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
material.map = texture
material.color.setHex(0xffffff)
material.needsUpdate = true
},
undefined,
() => { material.color.setHex(0x1f2933); material.opacity = 0.6 },
)
}
_makeVisualEntity(entity) {
const color = this._colorFor(entity)
const points = entity.path.map((p) => this._toWorld(this._renderPoint(p)))
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 })
const line = new THREE.Line(lineGeometry, lineMaterial)
this.pathGroup.add(line)
const radius = 4.4
let model
if (this.unitTemplate && entity.type === 'ground') {
model = this.unitTemplate.clone(true)
model.traverse((child) => {
if (!child.isMesh) return
child.material = new THREE.MeshStandardMaterial({
color, roughness: 0.72, metalness: 0.06, flatShading: true,
emissive: new THREE.Color(color).multiplyScalar(0.22),
})
})
} else {
const geo = new THREE.SphereGeometry(radius, 18, 12)
const mat = new THREE.MeshStandardMaterial({
color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08,
})
model = new THREE.Mesh(geo, mat)
}
const arrow = new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0),
radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9,
)
arrow.line.material.transparent = true
arrow.line.material.opacity = 0.78
arrow.cone.material.transparent = true
arrow.cone.material.opacity = 0.95
const group = new THREE.Group()
group.add(model, arrow)
this.markerGroup.add(group)
return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null }
}
async _loadTankTemplate() {
try {
const object = await new Promise((resolve, reject) => {
const loader = new OBJLoader()
loader.setPath(MODEL_PATH)
loader.load('t_34_obj.obj', resolve, undefined, reject)
})
if (this.disposed) return
const planes = []
object.traverse((child) => {
if (!child.isMesh) return
child.castShadow = false
child.receiveShadow = false
if (/^Plane/i.test(child.name || '')) { planes.push(child); return }
})
for (const plane of planes) plane.removeFromParent()
const box = new THREE.Box3()
object.traverse((child) => { if (child.isMesh) box.expandByObject(child) })
if (box.isEmpty()) box.setFromObject(object)
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 1)
const scale = 70 / maxDim
const center = box.getCenter(new THREE.Vector3())
const pivot = new THREE.Group()
object.position.x -= center.x
object.position.z -= center.z
object.position.y -= box.min.y
pivot.add(object)
pivot.scale.setScalar(scale)
this.unitTemplate = pivot
// Rebuild ground entities now that the model is available.
this._buildScene()
this.setTime(this.currentT)
} catch {
// No model -> spheres are used; nothing else to do.
}
}
// ---- capture zones -------------------------------------------------------
_buildCaptureZones() {
for (const zone of this.captureZones) {
const center = this._toWorld(this._renderPoint(zone.center))
const radius = Math.max(8, zone.radius * this.scale)
const group = new THREE.Group()
group.position.set(center.x, 1.05, center.z)
const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96)
ringGeo.rotateX(-Math.PI / 2)
const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide,
}))
ring.renderOrder = 8
const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96)
fillGeo.rotateX(-Math.PI / 2)
const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({
color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
}))
fill.position.y = 0.02
fill.renderOrder = 7
group.add(fill, ring)
group.userData = { zone, fill }
this.zoneGroup.add(group)
}
}
// Step interpolation, matching the 2D engine's _interpSeries(series, t, true).
_captureValueAt(zone, t) {
const cap = zone.cap
if (!cap.length) return 0
if (t <= cap[0][0]) return cap[0][1]
const last = cap[cap.length - 1]
if (t >= last[0]) return last[1]
for (let i = 1; i < cap.length; i++) {
if (cap[i][0] >= t) return cap[i - 1][1]
}
return last[1]
}
_updateCaptureZones() {
for (const group of this.zoneGroup.children) {
const { zone, fill } = group.userData
if (!zone || !fill) continue
// Same logic as the 2D engine: owner is slot 2 if value>0 else slot 1,
// coloured green when it matches the winning slot, red otherwise.
const value = this._captureValueAt(zone, this.currentT)
const frac = Math.min(1, Math.abs(value) / 100)
if (frac <= 0.01) { fill.visible = false; continue }
const ownerSlot = value > 0 ? 2 : 1
fill.visible = true
fill.material.color.set(ownerSlot === this.winnerSlot ? WIN_COLOR : LOSE_COLOR)
fill.material.opacity = 0.18 + frac * 0.34
const s = Math.sqrt(frac)
fill.scale.set(s, s, 1)
}
}
// ---- kill lines ----------------------------------------------------------
_buildKillLines() {
this.killLines = []
for (const k of this.kills) {
const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z }))
const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z }))
start.y += 12; end.y += 12
const vec = end.clone().sub(start)
const length = vec.length()
if (length < 0.5) continue
const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR)
const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color,
Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055)))
for (const m of [arrow.line.material, arrow.cone.material]) {
m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false
}
arrow.renderOrder = 18
arrow.visible = false
this.killLineGroup.add(arrow)
this.killLines.push({ time: k.time, arrow })
}
}
_updateKillLines() {
for (const { time, arrow } of this.killLines) {
const age = this.currentT - time
const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS
arrow.visible = visible
if (!visible) continue
const opacity = 1 - age / KILL_LINE_WINDOW_MS
arrow.line.material.opacity = 0.18 + opacity * 0.62
arrow.cone.material.opacity = 0.24 + opacity * 0.72
}
}
// ---- interpolation -------------------------------------------------------
_findSegment(entity, t) {
const path = entity.path
if (t < entity.startT || t > entity.endT) return null
let lo = 0, hi = path.length - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (path[mid].t <= t) lo = mid; else hi = mid
}
return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] }
}
_interp(entity, t) {
const seg = this._findSegment(entity, t)
if (!seg) return null
const { index, a, b } = seg
const path = entity.path
const span = Math.max(1, b.t - a.t)
const alpha = Math.min(1, Math.max(0, (t - a.t) / span))
const before = path[Math.max(0, index - 1)]
const after = path[Math.min(path.length - 1, index + 2)]
return {
t,
x: catmullRom(before.x, a.x, b.x, after.x, alpha),
y: catmullRom(before.y, a.y, b.y, after.y, alpha),
z: catmullRom(before.z, a.z, b.z, after.z, alpha),
}
}
_directionAt(entity, t) {
const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS)
const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS)
if (afterT <= beforeT) return null
const before = this._interp(entity, beforeT)
const after = this._interp(entity, afterT)
if (!before || !after) return null
const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before)))
if (dir.lengthSq() < 0.0001) return null
return dir.normalize()
}
_blendedDirection(visual, t) {
const raw = this._directionAt(visual.entity, t)
if (!raw) return visual.smoothedDirection
const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800
if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone()
else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize()
visual.lastDirectionT = t
return visual.smoothedDirection
}
// ---- highlight / focus ---------------------------------------------------
_applyHighlight() {
const sel = this.selectedPlayerId
if (!this.visualEntities) return
for (const visual of this.visualEntities) {
const isSel = sel != null && visual.entity.playerId === sel
const teamColor = this._colorFor(visual.entity)
const mat = visual.line.material
if (sel == null) {
mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0
} else if (isSel) {
mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30
} else {
mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0
}
if (visual.model?.isGroup) {
visual.model.traverse((child) => {
if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1
})
}
}
}
focus(playerId) {
this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId
this._applyHighlight()
if (this.selectedPlayerId == null) return
const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible)
|| (this.visualEntities || []).find((v) => v.entity.playerId === playerId)
if (!visual) return
const p = this._interp(visual.entity, this.currentT)
if (!p) return
const world = this._toWorld(this._renderPoint(p))
const offset = this.camera.position.clone().sub(this.controls.target)
this.controls.target.copy(world)
this.camera.position.copy(world.clone().add(offset))
this.controls.update()
}
// ---- public API ----------------------------------------------------------
setTime(t) {
this.currentT = t
if (!this.visualEntities) return
for (const visual of this.visualEntities) {
const p = this._interp(visual.entity, t)
visual.group.visible = Boolean(p)
if (!p) continue
visual.group.position.copy(this._toWorld(this._renderPoint(p)))
const dir = this._blendedDirection(visual, t)
visual.arrow.visible = Boolean(dir)
if (dir) {
visual.arrow.setDirection(dir)
if (visual.model?.isGroup) {
const flat = dir.clone(); flat.y = 0
if (flat.lengthSq() > 0) {
flat.normalize()
visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat)
visual.model.rotateY(Math.PI)
visual.model.rotateY(-Math.PI / 2)
}
}
}
}
this._updateCaptureZones()
this._updateKillLines()
}
setMode(mode) {
const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground'
if (next === this._mode) return
this._mode = next
this.mapInfo = this._resolveMapInfo(next)
this.smoothByEntity.clear()
this._buildScene()
this._loadMapTexture()
this.setTime(this.currentT)
}
resize() {
const w = this.container.clientWidth || this.container.offsetWidth || 720
const h = this.container.clientHeight || w
this.renderer.setSize(w, h, false)
this.camera.aspect = w / Math.max(1, h)
this.camera.updateProjectionMatrix()
}
_animate() {
if (this.disposed) return
this.controls.update()
this.renderer.render(this.scene, this.camera)
this._raf = requestAnimationFrame(this._animate)
}
dispose() {
this.disposed = true
if (this._raf) cancelAnimationFrame(this._raf)
this.controls?.dispose()
this._clearGroup(this.mapGroup)
this._clearGroup(this.pathGroup)
this._clearGroup(this.markerGroup)
this._clearGroup(this.zoneGroup)
this._clearGroup(this.killLineGroup)
this.renderer?.dispose()
if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl)
}
}
+112
View File
@@ -0,0 +1,112 @@
# Blog posts
Add a `.md` file in `frontend/src/blog/posts` and it will appear on `/blog` after the Vite dev server refreshes or the site is rebuilt.
Use this front matter at the top:
```md
---
title: Your post title
date: 2026-06-22
author: Heidi
excerpt: Short one-line summary for the blog list.
---
Write the post here.
```
The URL slug comes from the filename, so `new-feature.md` becomes `/blog/new-feature`. You can override it with `slug: custom-url`.
## Markdown examples
Headings:
```md
# Huge heading
## Section heading
### Small heading
```
Paragraphs:
```md
This is a normal paragraph.
Leave a blank line to start a new paragraph.
```
Bullet points:
```md
- First bullet
- Second bullet
- Third bullet
```
Numbered lists:
```md
1. First step
2. Second step
3. Third step
```
Blockquotes:
```md
> This text gets shown as a quote/callout.
```
Links:
```md
[The homepage](/)
[Discord](https://discord.com/)
```
Inline code:
```md
Use `frontend/src/blog/posts` for blog posts.
```
Code blocks:
````md
```js
console.log('hello from the blog')
```
````
## Images and videos
Put local images and videos in `frontend/public`. Files in there are served from the site root.
Example local file paths:
```txt
frontend/public/images/news/screenshot.png
frontend/public/videos/news/launch.mp4
```
Embed an image:
```md
![Image caption or alt text](/images/news/screenshot.png)
```
Embed a local video:
```md
@[video](/videos/news/launch.mp4)
```
Embed YouTube or Vimeo:
```md
@[youtube](https://www.youtube.com/watch?v=VIDEO_ID)
@[vimeo](https://vimeo.com/123456789)
```
YouTube embeds use the privacy-friendly `youtube-nocookie.com` player. Direct video files support `.mp4`, `.webm`, and `.ogg`.
+35
View File
@@ -0,0 +1,35 @@
---
title: Update on Preventing Abuse By Bad Actors
date: 2026-06-28
author: Heidi
excerpt: A conclusion on how we plan to tackle undesireable usage of the site :3
---
# ♥ Awruff :3
Hi all, again,
Today we'll be talking about actions taken to prevent and limit usage by undesireables.
# Who is affected
```md
Team: TPC
Users:
F-2А, 165569402 , EstonianAviator, Bad Actor Affiliation
kаspersky, 86157459, idk, Bad Actor Affiliation
Tonitrus_, 33536334, idk, Bad Actor Affiliation
xbirbx, 41808996, idk, Bad Actor Affiliation
Dеviss, 3651161, DEVISS, PEDOPHILE!!!!!!!
```
This team has been blocked
![Example of a blocked userpage](/images/news/blockeduser.png)
## How does this affect you?
Honestly, it shouldn't, on their DEBUT return they set a LEGENDARY kd of 0.3 and their team captain had 0 kills, 0 assists and 0 games won with 6 consecutive deaths, its kinda funny? I don't really know.
Love you all!!!!! ♥♥♥
Remember to join our [Discord](https://discord.gg/kP45rcU2Jx) :3
Mwahhh~~
+46
View File
@@ -0,0 +1,46 @@
---
title: Preventing Abuse By Bad Actors
date: 2026-06-27
author: Heidi
excerpt: A brief on how we plan to tackle undesireable usage of the site :3
---
# ♥ Awruff :3
Hi all, again, sorry for the random blog post being dropped at 10pm on a Saturday, lol.
Just a brief overview of what we're going to be doing to prevent bad actors from being able to properly utilise the site.
# Why?
A pedophile returned to TSS and I really dont like that, I dont think you should have to see their name on a page :3
## usage prevention.
Currently, my working theory is that we could implement discord sign on, and block specific bad actors (TPC) from being able to utilise the site (blocking their discord accounts), however this would remove a lot of functionality from non logged in users, which I dont reeaally want..
Alternatively, I could just vandilise their user pages and team page, as well as blocking them from search results..
Open to suggestions, but I dont really want to have to make an account for everyone.. it would suck if you have to log in :(
Either way, if you have any ideas or such, feel free to drop them in our [Discord](https://discord.gg/kP45rcU2Jx) :3
Love you all!!!!!
Have fun!!!!!!!! ♥♥♥
Current list of users (to be) blocked:
```md
Team: TPC
Users:
F-2А, 165569402 , EstonianAviator, Bad Actor Affiliation
kаspersky, 86157459, idk, Bad Actor Affiliation
Tonitrus_, 33536334, idk, Bad Actor Affiliation
xbirbx, 41808996, idk, Bad Actor Affiliation
Dеviss, 3651161, DEVISS, PEDOPHILE!!!!!!!
```
Dоn't be a creep :3
![I find it REALLY funny that he came back only to lose 6-0 in his debut major's playoffs LMAO](/images/news/TPC.png)
+28
View File
@@ -0,0 +1,28 @@
---
title: Our Launch & The Future
date: 2026-06-22
author: Heidi
excerpt: A brief discussion about the site and the future.
---
# ♥ Awruff :3
Hi all, glad to see you all (mostly) like the page :3 (over 1000 page views in 24hrs (allegedly) + nothing but positive feedback)
Just a brief overview of what we're going to be adding in the future
## stuff:
- 3D maps for all played maps (in progress, should be done within the next few weeks)
@[video](/videos/news/3d_map_preview.mp4)
- The ability to predict where a player might go on a map, basically just showing all their prior paths as either a heatmap or a couple lines with % probability, based on their historical games
- Missile timings to the viewer (time to impact, etc)
- Generally just more 3D viewer stuff, like chaff deployment, flare deployment
- accurate 3d models for vehicles, player cones of vision, etc.
Honestly, I dont really know what to add.. lol, if you have any suggestions for stuff you'd like to see, feel free to suggest them on our [Discord](https://discord.gg/kP45rcU2Jx), basically any suggestion will be reviewed :3
Have fun!!!!!!!! ♥♥♥
+260
View File
@@ -0,0 +1,260 @@
// Pure bracket-layout logic for the tournament detail page.
//
// The TSS API hands us authoritative matches, but `round` is numbered *per
// `type_bracket`* — the winner tree, the loser tree, and the special
// `LooserFinal`/`Semifinal`/`Final` stages each restart at round 0. Grouping by
// raw round therefore collapses unrelated stages into one column (e.g. the loser
// Semifinal landing in loser "Round 1"). This module maps each match to a
// (side, stage, round) coordinate so columns come out in true bracket order, and
// drops structural byes so they never render as "Team vs BYE" nodes.
//
// Kept framework-free so it can be unit-tested with plain node.
function lower(value) {
return String(value || '').toLowerCase()
}
export function cleanName(value) {
return String(value || '').trim()
}
// A "structural bye" is a slot that exists only to pad the bracket: a team that
// advanced unopposed, or a placeholder whose feeders were themselves byes. We
// hide these. A match with one known team and NO winner yet is *not* a bye —
// it's a real pending matchup awaiting an upstream result (grand final, loser
// bracket final), so it stays.
export function isStructuralBye(match) {
const a = cleanName(match.team_a_name)
const b = cleanName(match.team_b_name)
if (a && b) return false
if (!a && !b) return true
return Boolean(cleanName(match.winner_name)) || match.status === 'bye'
}
// Map a TSS `type_bracket` to a display side + stage. `stage` orders the special
// terminal columns after the numbered rounds within a side:
// winner: Winner rounds (0) → grand/championship Final (1)
// loser: Looser rounds (0) → LooserFinal (1) → Semifinal/lower final (2)
export function classifyBracket(typeBracket, hasLoserSide) {
const t = lower(typeBracket)
if (t.includes('swiss')) return { side: 'swiss', stage: 0 }
if (t.includes('group')) return { side: 'group', stage: 0 }
if (t.includes('looserfinal') || t.includes('loserfinal')) return { side: 'loser', stage: 1 }
if (t.includes('looser') || t.includes('loser')) return { side: 'loser', stage: 0 }
if (t.includes('semifinal')) {
// Double-elim: the Semifinal is the lower-bracket final (loser side). Single
// elim: it's just a normal winner-side stage.
return hasLoserSide ? { side: 'loser', stage: 2 } : { side: 'winner', stage: 0 }
}
if (t === 'final' || t.endsWith('final')) {
// Terminal column of the winner side (championship / grand final).
return { side: 'winner', stage: 1 }
}
if (t.includes('winner')) return { side: 'winner', stage: 0 }
return { side: 'winner', stage: 0 }
}
const SIDE_ORDER = { winner: 0, loser: 1, group: 2, swiss: 3, matches: 4 }
const SIDE_LABELS = {
winner: 'Winner bracket',
loser: 'Loser bracket',
group: 'Group matches',
swiss: 'Swiss matches',
matches: 'Matches',
}
function asInt(value, fallback) {
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
function compareMatches(a, b) {
return asInt(a.position, Number.MAX_SAFE_INTEGER) - asInt(b.position, Number.MAX_SAFE_INTEGER)
|| String(a.match_id).localeCompare(String(b.match_id))
}
// Label a bracket column. Numbered rounds read "Round N" by their position in
// the side; the terminal winner stage reads Final / Grand Final.
function columnLabel(side, stage, index, total, hasLoserSide) {
if (side === 'winner' && stage === 1) {
return hasLoserSide ? 'Grand Final' : 'Final'
}
return `Round ${index + 1}`
}
// Build the ordered bracket model from authoritative match rows.
// Returns { sides: [{ key, label, kind, columns: [{ id, label, matches }] }] }
// where kind is 'bracket' (elimination, drawn as a tree) or 'list' (group/swiss,
// drawn as a flat card list).
export function buildBracket(matches) {
const cleaned = (matches || []).filter((m) => !isStructuralBye(m))
const hasLoserSide = cleaned.some((m) => {
const t = lower(m.type_bracket)
return t.includes('looser') || t.includes('loser') || lower(m.side) === 'loser'
})
// Group by (side, stage, round) → one column each.
const columnMap = new Map()
for (const match of cleaned) {
const { side, stage } = classifyBracket(match.type_bracket, hasLoserSide)
const round = asInt(match.round, 0)
const key = `${side}:${stage}:${round}`
if (!columnMap.has(key)) {
columnMap.set(key, { side, stage, round, matches: [] })
}
columnMap.get(key).matches.push(match)
}
// Bucket columns by side, ordered by (stage, round).
const sideMap = new Map()
for (const column of columnMap.values()) {
column.matches.sort(compareMatches)
if (!sideMap.has(column.side)) sideMap.set(column.side, [])
sideMap.get(column.side).push(column)
}
const sides = [...sideMap.entries()]
.map(([key, columns]) => {
columns.sort((a, b) => a.stage - b.stage || a.round - b.round)
const isList = key === 'group' || key === 'swiss' || key === 'matches'
return {
key,
label: SIDE_LABELS[key] || key,
kind: isList ? 'list' : 'bracket',
columns: columns.map((column, index) => ({
id: `${key}:${column.stage}:${column.round}`,
label: columnLabel(key, column.stage, index, columns.length, hasLoserSide),
matches: column.matches,
})),
}
})
.sort((a, b) => (SIDE_ORDER[a.key] ?? 9) - (SIDE_ORDER[b.key] ?? 9))
return { sides, hasLoserSide }
}
// Which match in the next column a given match feeds into, by slot position.
// Winner tree and loser "major" rounds halve (position → floor/2); loser "minor"
// rounds (drop-ins, same width) map identically. We infer halving vs identity
// from the column node counts. Returns the parent position, or null.
export function parentPosition(childPosition, currentCount, nextCount) {
if (childPosition == null) return null
if (nextCount >= currentCount) return childPosition
return Math.floor(childPosition / 2)
}
// The match in `columns[columnIndex + 1]` that a given match feeds into. Found by
// slot position (faithful to the real tree even with byes hidden); falls back to
// a proportional index if the exact parent slot was itself a hidden bye.
export function feederParent(match, columnIndex, columns) {
const cur = columns[columnIndex]
const next = columns[columnIndex + 1]
if (!next) return null
const pPos = parentPosition(Number(match.position), cur.matches.length, next.matches.length)
const exact = next.matches.find((m) => Number(m.position) === pPos)
if (exact) return exact
const order = cur.matches.indexOf(match)
const idx = Math.min(
next.matches.length - 1,
Math.floor((order * next.matches.length) / cur.matches.length),
)
return next.matches[idx] || null
}
export function layoutKey(columnIndex, match) {
return `${columnIndex}:${match.match_id}`
}
// Position every match vertically so each one sits centred between the matches
// that feed into it — the classic bracket look. Pure: takes measured card heights,
// returns absolute `top` offsets keyed by `layoutKey`.
//
// Column 0 stacks top-to-bottom. Later columns centre each match on the mean of
// its feeders' centres. Matches whose feeders were all hidden byes (no anchor)
// are *extrapolated* from the nearest anchored sibling using the column's slot
// pitch — including above y=0 — rather than stacked at the top, which would shove
// the anchored matches away from their feeders and make the connectors fan and
// cross. A final per-side shift pulls any negative offsets back on-screen.
export function computeBracketLayout(columns, heights, gap = 16) {
const heightOf = (c, m) => heights.get(layoutKey(c, m)) || 92
const centers = columns.map(() => new Map())
columns.forEach((column, c) => {
const matches = column.matches
const hs = matches.map((m) => heightOf(c, m))
const pitch = (hs.reduce((a, b) => a + b, 0) / Math.max(1, hs.length)) + gap
// Anchor = mean of this match's feeders' centres (null when all were byes).
const childCenters = new Map()
if (c > 0) {
for (const child of columns[c - 1].matches) {
const parent = feederParent(child, c - 1, columns)
if (!parent) continue
const center = centers[c - 1].get(child.match_id)
if (center == null) continue
if (!childCenters.has(parent.match_id)) childCenters.set(parent.match_id, [])
childCenters.get(parent.match_id).push(center)
}
}
const center = matches.map((m) => {
const kids = childCenters.get(m.match_id)
return kids && kids.length ? kids.reduce((a, b) => a + b, 0) / kids.length : null
})
const firstAnchor = center.findIndex((v) => v != null)
if (firstAnchor === -1) {
// No anchors (column 0, or a fully-bye column): plain stack.
let cursor = 0
matches.forEach((m, i) => {
center[i] = cursor + hs[i] / 2
cursor += hs[i] + gap
})
} else {
// Leading byes: extrapolate upward (may go negative — fixed by the shift).
for (let i = firstAnchor - 1; i >= 0; i -= 1) center[i] = center[i + 1] - pitch
// Trailing / interior byes: extend down / interpolate between anchors.
let lastAnchor = firstAnchor
for (let i = firstAnchor + 1; i < matches.length; i += 1) {
if (center[i] != null) { lastAnchor = i; continue }
let next = i
while (next < matches.length && center[next] == null) next += 1
if (next >= matches.length) {
center[i] = center[lastAnchor] + (i - lastAnchor) * pitch
} else {
const span = next - lastAnchor
center[i] = center[lastAnchor] + ((center[next] - center[lastAnchor]) * (i - lastAnchor)) / span
}
}
// Resolve any residual overlap by nudging downward (rare; honours heights).
for (let i = 1; i < matches.length; i += 1) {
const minC = center[i - 1] + (hs[i - 1] + hs[i]) / 2 + gap
if (center[i] < minC) center[i] = minC
}
}
matches.forEach((m, i) => centers[c].set(m.match_id, center[i]))
})
// Shift the whole side so the highest match sits at y=0, then size to content.
let minTop = Infinity
columns.forEach((column, c) => {
column.matches.forEach((m) => {
minTop = Math.min(minTop, centers[c].get(m.match_id) - heightOf(c, m) / 2)
})
})
const shift = Number.isFinite(minTop) && minTop < 0 ? -minTop : 0
const tops = new Map()
let height = 0
columns.forEach((column, c) => {
column.matches.forEach((m) => {
const h = heightOf(c, m)
const top = centers[c].get(m.match_id) - h / 2 + shift
tops.set(layoutKey(c, m), top)
height = Math.max(height, top + h)
})
})
return { tops, centers, height }
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client'
import './styles.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)
File diff suppressed because it is too large Load Diff