feat: make flat-stack-presejpacky standalone with Docker and Makefile

This commit is contained in:
2026-06-07 16:48:15 +02:00
parent 164230356c
commit b7e4bbb49d
21 changed files with 5110 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
import { useState, useEffect } from 'react';
import { FieldCanvas } from './components/FieldCanvas';
import { DrillTimeline } from './components/DrillTimeline';
import { STEPS, getPositionsForStep } from './lib/drill';
import { Play, Pause, RotateCcw, LayoutTemplate, Info, X, SkipBack, SkipForward } from 'lucide-react';
import './index.css';
export default function App() {
const [cycle, setCycle] = useState(0);
const [step, setStep] = useState(0);
const [playing, setPlaying] = useState(false);
const [showInfo, setShowInfo] = useState(false);
useEffect(() => {
if (!playing) return;
const currentDuration = STEPS[step].duration;
// Automatically transition to the next step
const timer = setTimeout(() => {
if (step === STEPS.length - 1) {
setStep(0);
setCycle(c => c + 1);
} else {
setStep(s => s + 1);
}
}, currentDuration);
return () => clearTimeout(timer);
}, [playing, step, cycle]);
// Derive all rendered positions mathematically from cycle and step
const { pos, discX, discY, markX, markY, markAngle } = getPositionsForStep(cycle, step);
return (
<div className="flex flex-col h-screen w-full bg-slate-900 text-slate-100 font-sans overflow-hidden">
{/* Header Navigation */}
<header className="flex items-center justify-between px-6 lg:px-8 py-4 bg-slate-800/50 border-b border-slate-700 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center shadow-lg shadow-indigo-500/20">
<LayoutTemplate className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight text-white flex items-center gap-2">
Half-Flat Break-Side Pivot
<button
onClick={() => setShowInfo(true)}
className="ml-2 p-1 text-slate-400 hover:text-indigo-400 hover:bg-slate-800 rounded-full transition-colors"
title="Drill Description"
>
<Info className="w-5 h-5" />
</button>
</h1>
<p className="text-[10px] sm:text-xs text-slate-400 uppercase tracking-widest leading-none mt-1">Tactical Drill Simulator</p>
</div>
</div>
</header>
{/* Main Workspace */}
<main className="flex flex-1 overflow-hidden relative">
{/* Sidebar Controls */}
<aside className="w-80 border-r border-slate-700 bg-slate-800/30 p-6 flex-col gap-6 overflow-y-auto shrink-0 hidden lg:flex custom-scrollbar">
<section className="bg-slate-800/50 border border-slate-700/50 p-4 rounded-xl shadow-inner">
<h3 className="text-[10px] font-bold text-indigo-400 uppercase mb-2 tracking-widest">Current Action Focus</h3>
<h4 className="text-base font-semibold text-white mb-2">{STEPS[step].name}</h4>
<p className="text-sm text-slate-300 leading-relaxed">
{STEPS[step].description}
</p>
</section>
<section className="flex-1 flex flex-col min-h-0">
<h3 className="text-xs font-bold text-slate-500 uppercase mb-4 flex justify-between items-center">
Active Rotation
<span className="px-2 py-0.5 bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 font-mono text-[10px] rounded">
CYCLE {cycle + 1}
</span>
</h3>
<DrillTimeline currentStep={step} cycle={cycle} />
</section>
</aside>
{/* Tactical Field Visualizer */}
<div className="flex-1 relative bg-slate-950 p-4 md:p-12 flex flex-col items-center justify-center overflow-hidden">
<div className="w-full flex-1 max-h-[800px] flex items-center justify-center p-4">
<div className="w-full max-w-[500px] aspect-[5/7] h-full max-h-full">
<FieldCanvas
pos={pos}
discX={discX}
discY={discY}
markX={markX}
markY={markY}
markAngle={markAngle}
stepDuration={STEPS[step].duration}
step={step}
/>
</div>
</div>
{/* Overlay Legend */}
<div className="absolute bottom-6 md:bottom-12 right-6 md:right-12 bg-slate-800/80 backdrop-blur-md border border-slate-700 p-3 sm:p-4 rounded-xl flex gap-4 sm:gap-6 shadow-xl z-20">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.5)]"></div>
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Handler</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-slate-400 rounded-full border border-slate-300"></div>
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Cutter</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-600 rounded-sm rotate-45"></div>
<span className="text-[10px] uppercase font-bold text-slate-300 tracking-wider">Defense</span>
</div>
</div>
</div>
{/* Info Modal Overlay */}
{showInfo && (
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl max-w-2xl w-full flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-6 border-b border-slate-800 bg-slate-800/30">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Info className="w-6 h-6 text-indigo-400" /> Drill Overview
</h2>
<button
onClick={() => setShowInfo(false)}
className="p-2 bg-slate-800 hover:bg-slate-700 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[70vh] flex flex-col gap-4 text-slate-300">
<p className="text-base leading-relaxed">
The <strong className="text-white">Isolation Break Drill</strong> focuses on exploiting the defense using handler movement to open up the break side from a half-flatstack setup.
</p>
<div className="bg-slate-800/50 p-4 rounded-xl border border-slate-700">
<h3 className="font-semibold text-indigo-300 mb-3 uppercase tracking-wider text-xs">Sequence of Motions</h3>
<ol className="list-decimal list-outside ml-4 space-y-3 text-sm">
<li><strong className="text-slate-100">Initiation:</strong> The breakside handler drives hard to the open side, threatening a give-and-go cut. The mark tracks this movement.</li>
<li><strong className="text-slate-100">The Fake:</strong> The central handler (with the disc) fakes a throw to the open side, forcing the mark to bite and shift their weight fully into the open lane.</li>
<li><strong className="text-slate-100">The Pivot:</strong> Exploiting the mark's overcommitment, the central handler rapidly pivots back to the breakside, stepping around the off-balance defender.</li>
<li><strong className="text-slate-100">The Cut:</strong> Synchronized perfectly with the thrower's pivot, the cutter on the line (breakside) makes a hard cut parallel to the sideline towards the disc.</li>
<li><strong className="text-slate-100">The Throw:</strong> The handler delivers a breakside pass into space, allowing the cutter to catch it in stride and continue advancing upfield.</li>
</ol>
</div>
<div className="bg-emerald-900/20 border border-emerald-500/30 p-4 rounded-xl">
<p className="text-sm text-emerald-200 leading-relaxed">
<span className="font-bold text-emerald-400">Coaching Tip:</span> Ensure the line cutter doesn't start their cut too early. They must wait for the central handler's eyes to snap back to the break side before initiating the horizontal cut so they arrive exactly on time.
</p>
</div>
</div>
</div>
</div>
)}
</main>
{/* Playback Control Bar */}
<footer className="h-20 lg:h-24 bg-slate-900 border-t border-slate-700 flex items-center px-6 lg:px-12 gap-6 lg:gap-8 shrink-0 z-10">
<div className="flex items-center gap-3 md:gap-4">
<button
onClick={() => {
setPlaying(false);
if (step > 0) setStep(step - 1);
else if (cycle > 0) { setCycle(cycle - 1); setStep(STEPS.length - 1); }
}}
className="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50"
title="Previous Step"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={() => setPlaying(!playing)}
className="w-14 h-14 flex items-center justify-center bg-indigo-600 rounded-full shadow-lg shadow-indigo-500/30 hover:scale-105 active:scale-95 transition-all text-white border border-indigo-400/50"
title={playing ? "Pause" : "Play"}
>
{playing ? <Pause className="w-6 h-6 fill-current" /> : <Play className="w-6 h-6 fill-current ml-1" />}
</button>
<button
onClick={() => {
setPlaying(false);
if (step < STEPS.length - 1) setStep(step + 1);
else { setCycle(cycle + 1); setStep(0); }
}}
className="w-10 h-10 flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50"
title="Next Step"
>
<SkipForward className="w-4 h-4" />
</button>
<button
onClick={() => { setStep(0); setCycle(0); setPlaying(false); }}
className="w-10 h-10 hidden sm:flex items-center justify-center bg-slate-800 rounded-full border border-slate-700 hover:text-indigo-400 transition-colors text-slate-400 hover:border-indigo-500/50 ml-2"
title="Restart Drill"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
<div className="flex-1 flex flex-col gap-2 mx-2">
<div className="flex justify-between text-[10px] font-mono text-slate-500 uppercase tracking-widest px-1">
<span>{String((step * 0.45).toFixed(2)).padStart(5, '0')}</span>
<span className="font-semibold text-indigo-400 text-xs hidden sm:inline">{STEPS[step]?.name}</span>
<span>{String(((STEPS.length - 1) * 0.45).toFixed(2)).padStart(5, '0')}</span>
</div>
<div className="relative h-2 w-full bg-slate-800 rounded-full overflow-hidden border border-slate-700/50">
<div
className="absolute top-0 left-0 h-full bg-indigo-500 transition-all duration-300 ease-linear shadow-[0_0_10px_rgba(99,102,241,0.8)]"
style={{ width: `${Math.max(2, ((step) / Math.max(1, STEPS.length - 1)) * 100)}%` }}
></div>
<div className="absolute top-0 w-0.5 h-full bg-white shadow-[0_0_5px_white] transition-all duration-300 ease-linear" style={{ left: `calc(${Math.max(2, ((step) / Math.max(1, STEPS.length - 1)) * 100)}% - 2px)` }}></div>
</div>
</div>
<div className="hidden md:flex items-center gap-6 text-slate-400">
<div className="flex flex-col items-end">
<span className="text-[10px] font-bold uppercase tracking-tighter text-slate-500">Speed</span>
<span className="text-xs font-mono text-indigo-400 font-semibold">1.0x</span>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { STEPS } from '../lib/drill';
export function DrillTimeline({ currentStep }: { currentStep: number, cycle: number }) {
return (
<div className="space-y-2 overflow-y-auto custom-scrollbar flex-1 pr-1 pb-4">
{STEPS.map((s, i) => {
const isActive = i === currentStep;
const isPassed = i < currentStep;
if (isActive) {
return (
<div key={i} className="flex flex-col p-3.5 bg-indigo-500/10 border border-indigo-500/30 rounded-lg shadow-sm gap-2 transition-all">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-indigo-300">{s.name}</span>
<span className="text-xs bg-indigo-500 text-white px-2.5 py-0.5 rounded shadow-sm shadow-indigo-500/50 font-medium tracking-wide">Active</span>
</div>
</div>
);
} else if (isPassed) {
return (
<div key={i} className="flex flex-col p-3.5 bg-slate-700/20 border border-slate-700 rounded-lg opacity-60 gap-1.5 transition-all">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300">{s.name}</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">Done</span>
</div>
</div>
);
} else {
return (
<div key={i} className="flex flex-col p-3.5 bg-slate-800/30 border border-slate-700/50 rounded-lg opacity-40 gap-1.5 transition-all">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">{s.name}</span>
<span className="text-[10px] text-slate-600 uppercase tracking-widest font-semibold">Next</span>
</div>
</div>
);
}
})}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { motion } from 'motion/react';
import { PlayerId } from '../types';
import { PLAYERS_UI, P_B, P_D } from '../lib/drill';
interface FieldCanvasProps {
pos: Record<PlayerId, { x: number, y: number }>;
discX: number;
discY: number;
markX: number;
markY: number;
markAngle?: number;
stepDuration: number;
step: number;
}
export function FieldCanvas({ pos, discX, discY, markX, markY, markAngle = 0, stepDuration, step }: FieldCanvasProps) {
const transition = { duration: stepDuration / 1000, ease: 'easeInOut' };
return (
<div className="relative w-full h-full bg-emerald-900/50 rounded border-2 border-emerald-500/30 overflow-hidden shadow-[inset_0_0_40px_rgba(0,0,0,0.5)]">
{/* Field Markings HTML layer */}
<div className="absolute inset-0 grid grid-cols-4 divide-x divide-emerald-500/20 pointer-events-none">
<div></div><div></div><div></div><div></div>
</div>
<div className="absolute inset-0 flex flex-col justify-between py-[12%] px-0 pointer-events-none">
<div className="w-full h-[1px] bg-white/20 shadow-[0_0_2px_rgba(255,255,255,0.4)]"></div>
<div className="w-full h-[1px] bg-white/20 shadow-[0_0_2px_rgba(255,255,255,0.4)]"></div>
</div>
{/* SVG Animation layer */}
<svg
viewBox="0 0 500 700"
className="absolute inset-0 w-full h-full"
preserveAspectRatio="xMidYMid slice"
>
<defs>
<filter id="dropshadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" floodOpacity="0.4" floodColor="#020617" />
</filter>
</defs>
{/* Action Trajectory Paths */}
<g>
<path
d={`M ${P_B.x} ${P_B.y} Q 250 350 350 300`}
stroke="#818cf8"
strokeWidth="3"
strokeDasharray="6 6"
fill="none"
className="transition-opacity duration-300"
opacity={step >= 1 && step <= 6 ? 0.8 : 0}
/>
{step >= 1 && step <= 6 && (
<circle cx="350" cy="300" r="4" fill="#818cf8" />
)}
<path
d={`M ${P_D.x} ${P_D.y} L 100 250`}
stroke="#fbbf24"
strokeWidth="3"
strokeDasharray="6 6"
fill="none"
className="transition-opacity duration-300"
opacity={step >= 3 && step <= 6 ? 0.8 : 0}
/>
{step >= 3 && step <= 6 && (
<circle cx="100" cy="250" r="4" fill="#fbbf24" />
)}
</g>
{/* Players */}
{(Object.entries(pos) as [string, {x:number, y:number}][]).map(([idStr, coords]) => {
const id = parseInt(idStr) as PlayerId;
const ui = PLAYERS_UI[id];
// Determine styling based on position (handlers typically at bottom half)
const isHandler = coords.y > 400;
const fill = isHandler ? '#3b82f6' : '#94a3b8'; // Handler: blue, Cutter: slate
const labelPrefix = isHandler ? 'H' : 'C';
return (
<motion.g key={id} animate={{ x: coords.x, y: coords.y }} transition={transition} filter="url(#dropshadow)">
<circle cx="0" cy="0" r="16" fill={fill} stroke="#ffffff" strokeWidth="2.5" />
<text x="0" y="4" textAnchor="middle" fill="#ffffff" fontSize="11" fontWeight="bold" fontFamily="sans-serif">
{labelPrefix}{ui.label}
</text>
</motion.g>
);
})}
{/* Mark (Defender) */}
<motion.g animate={{ x: markX, y: markY }} transition={transition} filter="url(#dropshadow)">
<motion.g animate={{ rotate: markAngle }} transition={transition}>
{/* Arms span */}
<rect x="-22" y="-2" width="44" height="4" rx="2" fill="#ef4444" stroke="#7f1d1d" strokeWidth="0.5" />
{/* Hands */}
<circle cx="-22" cy="0" r="3.5" fill="#fca5a5" />
<circle cx="22" cy="0" r="3.5" fill="#fca5a5" />
{/* Head */}
<circle cx="0" cy="0" r="7" fill="#b91c1c" stroke="#ffffff" strokeWidth="1.5" />
</motion.g>
<text x="0" y="16" textAnchor="middle" fill="#ef4444" fontSize="10" fontWeight="bold" fontFamily="sans-serif">M</text>
</motion.g>
{/* Disc */}
<motion.g animate={{ x: discX, y: discY }} transition={transition}>
<g filter="url(#dropshadow)">
<circle r="9" fill="#ffffff" stroke="#94a3b8" strokeWidth="1" />
<circle r="6" fill="none" stroke="#e2e8f0" strokeWidth="1" />
<line x1="-4" y1="0" x2="4" y2="0" stroke="#cbd5e1" strokeWidth="1.5" />
</g>
</motion.g>
</svg>
</div>
);
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,130 @@
import { PlayerId, StepAction, PlayerRoles, Position } from '../types';
export const STEPS: StepAction[] = [
{ name: 'Setup', duration: 800, description: 'H2 (Central) holds disc. H1 (Breakside) prepares. C1 & C2 hold flatstack positions.' },
{ name: 'Initiate Cut', duration: 1000, description: 'H1 drives into the open side for a potential give-and-go. M (Mark) tracks the movement.' },
{ name: 'Fake Open Side', duration: 600, description: 'H2 fakes the pass to H1. M bites and shifts weight to block the open side throw.' },
{ name: 'Pivot & Break Cut', duration: 900, description: 'H2 quickly pivots to the breakside. C2 (Line Cutter) sees the pivot and cuts parallel to the sideline towards the disc.' },
{ name: 'Throw Break', duration: 500, description: 'H2 throws the break pass. C2 continues stride along the sideline to meet the disc.' },
{ name: 'Caught', duration: 600, description: 'C2 catches the disc in stride, gaining forward momentum.' },
{ name: 'Rotate', duration: 1400, description: 'H2 clears to breakside. H1 to C1. C1 to C2. C2 clears to queue. Queue enters as H2.' },
];
export const getCycleRoles = (cycle: number): PlayerRoles => {
const arr: PlayerId[] = [1, 2, 3, 4, 5];
const shifts = cycle % 5;
for (let i = 0; i < shifts; i++) {
const last = arr.pop();
if (last) arr.unshift(last);
}
return { A: arr[0], B: arr[1], C: arr[2], D: arr[3], E: arr[4] };
};
export const PLAYERS_UI: Record<PlayerId, { color: string; label: string }> = {
1: { color: '#3b82f6', label: '1' },
2: { color: '#eab308', label: '2' },
3: { color: '#a855f7', label: '3' },
4: { color: '#ec4899', label: '4' },
5: { color: '#f97316', label: '5' },
};
// Nominal positions for roles
export const P_A: Position = { x: 250, y: 450 }; // Central
export const P_B: Position = { x: 100, y: 450 }; // Breakside Side
export const P_C: Position = { x: 250, y: 200 }; // Cutter Middle
export const P_D: Position = { x: 100, y: 200 }; // Cutter Line
export const P_E: Position = { x: 250, y: 620 }; // Queue
export const getPositionsForStep = (cycle: number, step: number) => {
const roles = getCycleRoles(cycle);
const pos = {
[roles.A]: { ...P_A },
[roles.B]: { ...P_B },
[roles.C]: { ...P_C },
[roles.D]: { ...P_D },
[roles.E]: { ...P_E },
} as Record<PlayerId, Position>;
const discOffset = { x: 10, y: -10 };
let discHolder: PlayerId | null = roles.A;
let discX = P_A.x + discOffset.x;
let discY = P_A.y + discOffset.y;
let markX = P_A.x - 18;
let markY = P_A.y - 18;
let markAngle = -15; // Blocks space between both cutters
// 1: Initiate Cut
if (step >= 1) {
pos[roles.B] = { x: 250, y: 350 };
}
// 2: Fake Open Side
if (step >= 2) {
pos[roles.B] = { x: 350, y: 300 };
}
if (step === 2) {
discX = pos[roles.A].x + 35;
discY = pos[roles.A].y - 35;
// Mark bites slightly to challenge give and go cut
markX = pos[roles.A].x + 8;
markY = pos[roles.A].y - 15;
markAngle = 35; // Angles to block open side
}
// 3: Pivot & Break Cut
if (step >= 3 && step <= 4) {
discX = pos[roles.A].x - 28;
discY = pos[roles.A].y;
// Mark tries to recover but leaves lane open to the cutter
markX = P_A.x - 5;
markY = P_A.y - 25;
markAngle = -5; // Not fully rotated back to breakside angle
}
if (step === 3) {
// Cutter starts cut, but doesn't reach the end yet
pos[roles.D] = { x: 100, y: 220 };
}
if (step >= 4) {
// Cutter catches the disc further upfield, in stride
pos[roles.D] = { x: 100, y: 250 };
}
// 4: Throw Break
if (step === 4) {
discHolder = null;
discX = pos[roles.D].x;
discY = pos[roles.D].y;
}
// 5: Caught
if (step === 5) {
discHolder = roles.D;
discX = pos[roles.D].x + discOffset.x;
discY = pos[roles.D].y + discOffset.y;
}
// 6: Rotate
if (step === 6) {
pos[roles.A] = { ...P_B };
pos[roles.B] = { ...P_C };
pos[roles.C] = { ...P_D };
pos[roles.D] = { ...P_E };
pos[roles.E] = { ...P_A };
discHolder = roles.E;
discX = pos[roles.E].x + discOffset.x;
discY = pos[roles.E].y + discOffset.y;
markX = pos[roles.E].x - 18;
markY = pos[roles.E].y - 18;
markAngle = -15;
}
return { pos, discX, discY, markX, markY, markAngle };
};

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,20 @@
export type PlayerId = 1 | 2 | 3 | 4 | 5;
export type StepAction = {
name: string;
duration: number; // in ms
description: string;
};
export type PlayerRoles = {
A: PlayerId;
B: PlayerId;
C: PlayerId;
D: PlayerId;
E: PlayerId;
};
export type Position = {
x: number;
y: number;
};