Files
pickle_vision/src/web/templates/index.html
Ruslan Bakiev 43f11a7e40 fix: fixed header/footer, no scroll, 100vh layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:57:22 +07:00

990 lines
33 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>Pickle Vision - Referee System</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a1a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
overflow: hidden;
height: 100vh;
}
/* Header */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: #111128;
border-bottom: 1px solid #2a2a4a;
}
.logo { font-size: 20px; font-weight: 700; color: #4ecca3; }
.tabs { display: flex; gap: 4px; }
.tab {
padding: 8px 20px;
border: 1px solid #333;
border-radius: 6px;
background: transparent;
color: #888;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tab:hover { color: #ccc; border-color: #555; }
.tab.active {
background: #4ecca3;
color: #000;
border-color: #4ecca3;
font-weight: 600;
}
.status-bar {
display: flex;
gap: 16px;
font-size: 13px;
color: #666;
}
.status-bar .val { color: #4ecca3; font-weight: 600; }
/* Tab content */
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Camera feeds (detection tab) */
.cameras {
display: flex;
gap: 8px;
padding: 8px;
justify-content: center;
height: calc(100vh - 48px);
}
.cam-box {
flex: 1;
max-width: 50%;
position: relative;
}
.cam-box img {
width: 100%;
border-radius: 6px;
border: 1px solid #222;
display: block;
}
.cam-label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,0.7);
color: #4ecca3;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
/* 3D viewport */
.viewport-3d {
width: 100%;
height: 400px;
border-top: 1px solid #222;
position: relative;
}
.viewport-3d canvas { width: 100% !important; height: 100% !important; }
/* Full-height tab layout */
.tab-content {
padding-top: 48px;
}
.tab-full {
height: calc(100vh - 48px - 100px);
}
.tab-full .viewport-3d {
width: 100%;
height: 100%;
border-top: none;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 8px;
background: #0d0d20;
border-top: 1px solid #222;
height: 100px;
}
.bottom-card {
width: 120px;
height: 90px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid #222;
overflow: hidden;
}
.bottom-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Sidebar panel (unused, kept for compat) */
.sidebar-panel { display: none; }
.btn-calibrate {
padding: 3px 8px;
background: #4ecca3;
color: #000;
border: none;
border-radius: 4px;
font-size: 9px;
font-weight: 600;
cursor: pointer;
}
.btn-calibrate:hover { background: #3dbb92; }
.btn-calibrate:disabled { background: #333; color: #666; cursor: not-allowed; }
.calibrate-status {
font-size: 8px;
color: #666;
}
.calibrate-status .ok { color: #4ecca3; }
.calibrate-status .fail { color: #ff4444; }
/* VAR panel inherits from .cam-card via shared rule above */
.var-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.var-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #333;
transition: background 0.3s;
}
.var-dot.active {
background: #ff3333;
box-shadow: 0 0 12px rgba(255, 51, 51, 0.6);
animation: var-pulse 1.5s infinite;
}
@keyframes var-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(255, 51, 51, 0.4); }
50% { box-shadow: 0 0 20px rgba(255, 51, 51, 0.8); }
}
.var-label { font-size: 14px; font-weight: 700; color: #888; }
.var-label.active { color: #ff3333; }
.var-info {
display: flex;
flex-direction: column;
justify-content: center;
font-size: 13px;
color: #888;
min-width: 200px;
}
.var-info .var-line { color: #e0e0e0; font-weight: 600; }
.var-info .var-timer { color: #ff6666; font-size: 16px; font-weight: 700; }
.var-info .var-dist { color: #aaa; }
.var-snapshot {
width: 100%;
border-radius: 4px;
border: 1px solid #333;
object-fit: cover;
background: #1a1a2e;
}
.var-snapshot.empty {
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 11px;
height: 60px;
}
/* Info card (same size as cam cards) */
.cam-card, .var-panel-bottom {
width: 120px;
height: 90px;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
padding: 4px 8px;
background: #111128;
border: 1px solid #2a2a4a;
border-radius: 4px;
font-size: 9px;
color: #888;
overflow: hidden;
}
.cc-title {
font-size: 8px;
font-weight: 700;
color: #555;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cc-item {
display: flex;
justify-content: space-between;
gap: 6px;
line-height: 1.2;
}
.cc-item b {
color: #4ecca3;
font-weight: 600;
}
.cc-divider {
border-top: 1px solid #2a2a4a;
margin: 1px 0;
}
/* Event banner */
.event-banner {
display: none;
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 60, 60, 0.95);
color: white;
padding: 12px 32px;
border-radius: 8px;
font-size: 18px;
font-weight: 700;
z-index: 100;
animation: pulse 1s infinite;
}
.event-banner.show { display: block; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Info panel */
.info-panel {
display: flex;
gap: 24px;
padding: 8px 24px;
background: #111128;
border-top: 1px solid #2a2a4a;
font-size: 13px;
justify-content: center;
flex-wrap: wrap;
}
.info-item .label { color: #666; }
.info-item .value { color: #4ecca3; font-weight: 600; margin-left: 4px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">Pickle Vision</div>
<div class="tabs">
<button class="tab active" data-tab="detection">Detection</button>
<button class="tab" data-tab="court">Court</button>
<button class="tab" data-tab="trajectory">Trajectory</button>
</div>
<div class="status-bar">
<div>CAM0: <span class="val" id="fps0">--</span> fps</div>
<div>CAM1: <span class="val" id="fps1">--</span> fps</div>
</div>
</div>
<div class="event-banner" id="eventBanner">VAR: Close Call</div>
<!-- Tab 1: Detection -->
<div class="tab-content active" id="tab-detection">
<div class="cameras">
<div class="cam-box">
<img id="det-cam1" alt="Camera 1">
</div>
<div class="cam-box">
<img id="det-cam0" alt="Camera 0">
</div>
</div>
</div>
<!-- Tab 2: Court — 3D main, cameras small bottom center -->
<div class="tab-content" id="tab-court">
<div class="tab-full">
<div class="viewport-3d" id="court-3d"></div>
<div class="bottom-bar">
<div class="bottom-card"><img id="court-cam1" alt="Camera 1"></div>
<div class="bottom-card"><img id="court-cam0" alt="Camera 0"></div>
<div class="cam-card">
<div class="cc-title">Base Setup</div>
<div class="cc-item">Distance <b id="paramPosY">1.0</b>m</div>
<div class="cc-item">Height <b id="paramPosZ">1.0</b>m</div>
<div class="cc-item">Stereo <b id="paramStereo">6</b>cm</div>
<div class="cc-item">Rotation <b id="paramAngle">15</b>°</div>
<div class="cc-item">HFOV <b id="paramHfov">128</b>°</div>
<div class="cc-item">Sensor <b>IMX219</b></div>
<div class="cc-divider"></div>
<button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()">Calibrate</button>
<div class="calibrate-status" id="calStatus">
<span id="calStatusText">Not calibrated</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tab 3: Trajectory — 3D main, cameras small bottom center -->
<div class="tab-content" id="tab-trajectory">
<div class="tab-full">
<div class="viewport-3d" id="trajectory-3d"></div>
<div class="bottom-bar">
<div class="bottom-card"><img id="traj-cam1" alt="Camera 1"></div>
<div class="bottom-card"><img id="traj-cam0" alt="Camera 0"></div>
<div class="var-panel-bottom">
<div class="var-indicator">
<div class="var-dot" id="varDot"></div>
<div class="var-label" id="varLabel">VAR</div>
</div>
<div class="var-line" id="varLine" style="color:#ccc;font-size:9px">No events</div>
<div class="var-timer" id="varTimer" style="color:#ff6666;font-size:10px;font-weight:700"></div>
<div class="var-dist" id="varDist" style="color:#888;font-size:9px"></div>
</div>
</div>
</div>
</div>
<div class="info-panel" id="infoPanel" style="display:none">
<div class="info-item"><span class="label">Speed:</span><span class="value" id="ball-speed">--</span></div>
<div class="info-item"><span class="label">Height:</span><span class="value" id="ball-height">--</span></div>
<div class="info-item"><span class="label">Landing:</span><span class="value" id="ball-landing">--</span></div>
<div class="info-item"><span class="label">Events:</span><span class="value" id="event-count">0</span></div>
</div>
<script>
// ===================== Tab switching =====================
var activeTab = '{{ active_tab | default("detection") }}';
function switchTab(target) {
activeTab = target;
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
document.querySelector('.tab[data-tab="' + target + '"]').classList.add('active');
document.getElementById('tab-' + target).classList.add('active');
document.getElementById('infoPanel').style.display = (target === 'trajectory') ? 'flex' : 'none';
if (target === 'court' && !courtSceneInitialized) initCourtScene();
if (target === 'trajectory' && !trajSceneInitialized) initTrajectoryScene();
history.pushState(null, '', '/' + target);
}
document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function(e) {
e.preventDefault();
switchTab(this.dataset.tab);
});
});
window.addEventListener('popstate', function() {
var path = location.pathname.replace('/', '') || 'detection';
switchTab(path);
});
// Init active tab from server
if (activeTab !== 'detection') switchTab(activeTab);
// ===================== Calibration =====================
function doCalibrate() {
var btn = document.getElementById('btnCalibrate');
btn.disabled = true;
btn.textContent = 'Calibrating...';
fetch('/api/calibration/trigger', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
if (data.ok) {
btn.textContent = 'Re-calibrate';
updateCalibrationStatus();
// Fetch camera positions and add to 3D scene
fetch('/api/calibration/data')
.then(function(r) { return r.json(); })
.then(function(camData) { addCamerasToScene(camData); });
} else {
btn.textContent = 'Calibrate Court';
alert('Calibration failed: ' + (data.error || 'Unknown error'));
}
})
.catch(function(e) {
btn.disabled = false;
btn.textContent = 'Calibrate Court';
alert('Error: ' + e);
});
}
function updateCalibrationStatus() {
fetch('/api/calibration/status')
.then(function(r) { return r.json(); })
.then(function(data) {
var el = document.getElementById('calStatusText');
var ok0 = data['0'], ok1 = data['1'];
if (ok0 && ok1) {
el.textContent = 'Calibrated';
el.className = 'ok';
} else if (ok0 || ok1) {
el.textContent = 'Partially calibrated';
el.className = 'ok';
} else {
el.textContent = 'Not calibrated';
el.className = '';
}
});
}
// Check on load
updateCalibrationStatus();
function addCamerasToScene(camData) {
if (!courtSceneInitialized) return;
for (var sid in camData) {
var cam = camData[sid];
var pos = cam.position;
// Camera pyramid
var geo = new THREE.ConeGeometry(0.3, 0.5, 4);
var mat = new THREE.MeshBasicMaterial({
color: sid === '0' ? 0x44aaff : 0xff44aa,
wireframe: true
});
var mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos[0], pos[1], pos[2]);
// Point cone toward look direction
var dir = cam.look_direction;
mesh.lookAt(pos[0] + dir[0], pos[1] + dir[1], pos[2] + dir[2]);
courtScene.add(mesh);
// Label
// (Three.js r128 doesn't have CSS2DRenderer built-in, so skip label for now)
}
}
// ===================== Camera frame polling =====================
var camPrefixes = { 'detection': 'det', 'court': 'court', 'trajectory': 'traj' };
function refreshCam(tabName, camId) {
var prefix = camPrefixes[tabName];
var img = document.getElementById(prefix + '-cam' + camId);
if (!img) return;
var newImg = new Image();
newImg.onload = function() {
img.src = newImg.src;
setTimeout(function() { refreshCam(tabName, camId); }, 50);
};
newImg.onerror = function() {
setTimeout(function() { refreshCam(tabName, camId); }, 500);
};
newImg.src = '/frame/' + camId + '?' + Date.now();
}
['detection', 'court', 'trajectory'].forEach(function(tab) {
refreshCam(tab, 0);
refreshCam(tab, 1);
});
// ===================== Stats polling =====================
setInterval(function() {
fetch('/api/stats').then(function(r) { return r.json(); }).then(function(d) {
document.getElementById('fps0').textContent = d['0'] ? d['0'].fps.toFixed(1) : '--';
document.getElementById('fps1').textContent = d['1'] ? d['1'].fps.toFixed(1) : '--';
});
}, 2000);
// ===================== Three.js: Court Scene (Tab 2) =====================
var courtSceneInitialized = false;
var courtScene, courtCamera, courtRenderer, courtBallMesh;
function initCourtScene() {
courtSceneInitialized = true;
var container = document.getElementById('court-3d');
var w = container.clientWidth;
var h = container.clientHeight;
courtScene = new THREE.Scene();
courtScene.background = new THREE.Color(0x0a0a1a);
courtCamera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
courtCamera.position.set(6.7, -6, 10);
courtCamera.lookAt(6.7, 3.05, 0);
courtRenderer = new THREE.WebGLRenderer({ antialias: true });
courtRenderer.setSize(w, h);
container.appendChild(courtRenderer.domElement);
var controls = new THREE.OrbitControls(courtCamera, courtRenderer.domElement);
controls.target.set(6.7, 3.05, 0);
controls.update();
// Court surface
var courtGeo = new THREE.PlaneGeometry(13.4, 6.1);
var courtMat = new THREE.MeshBasicMaterial({ color: 0x1a5c3a, side: THREE.DoubleSide });
var courtMesh = new THREE.Mesh(courtGeo, courtMat);
courtMesh.position.set(6.7, 3.05, 0);
courtScene.add(courtMesh);
drawCourtLines(courtScene);
// Net (wide plane)
var netMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4, side: THREE.DoubleSide });
var netWide = new THREE.PlaneGeometry(0.914, 6.1);
var netWideMesh = new THREE.Mesh(netWide, netMat);
netWideMesh.rotation.y = Math.PI / 2;
netWideMesh.position.set(6.7, 3.05, 0.457);
courtScene.add(netWideMesh);
// Ball
var ballGeo = new THREE.SphereGeometry(0.15, 16, 16);
var ballMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
courtBallMesh = new THREE.Mesh(ballGeo, ballMat);
courtBallMesh.visible = false;
courtScene.add(courtBallMesh);
courtScene.add(new THREE.AmbientLight(0xffffff, 0.8));
// Physical camera marker: 1m from net, center, 1m height
addStereocameras(courtScene, getCamParams());
// Load existing calibration cameras
fetch('/api/calibration/data')
.then(function(r) { return r.json(); })
.then(function(camData) { addCamerasToScene(camData); })
.catch(function() {});
function animateCourt() {
requestAnimationFrame(animateCourt);
controls.update();
courtRenderer.render(courtScene, courtCamera);
}
animateCourt();
window.addEventListener('resize', function() {
var w2 = container.clientWidth;
var h2 = container.clientHeight;
courtCamera.aspect = w2 / h2;
courtCamera.updateProjectionMatrix();
courtRenderer.setSize(w2, h2);
});
}
// ===================== Three.js: Trajectory Scene (Tab 3) =====================
var trajSceneInitialized = false;
var trajScene, trajCamera, trajRenderer, trajBallMesh, trajLine;
function initTrajectoryScene() {
trajSceneInitialized = true;
var container = document.getElementById('trajectory-3d');
var w = container.clientWidth;
var h = container.clientHeight;
trajScene = new THREE.Scene();
trajScene.background = new THREE.Color(0x0a0a1a);
trajCamera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
trajCamera.position.set(6.7, -6, 10);
trajCamera.lookAt(6.7, 3.05, 0);
trajRenderer = new THREE.WebGLRenderer({ antialias: true });
trajRenderer.setSize(w, h);
container.appendChild(trajRenderer.domElement);
var controls = new THREE.OrbitControls(trajCamera, trajRenderer.domElement);
controls.target.set(6.7, 3.05, 0);
controls.update();
// Court surface
var courtGeo = new THREE.PlaneGeometry(13.4, 6.1);
var courtMat = new THREE.MeshBasicMaterial({ color: 0x1a5c3a, side: THREE.DoubleSide });
var courtMesh = new THREE.Mesh(courtGeo, courtMat);
courtMesh.position.set(6.7, 3.05, 0);
trajScene.add(courtMesh);
drawCourtLines(trajScene);
// Net
var netMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
var netMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.914, 6.1), netMat);
netMesh.rotation.y = Math.PI / 2;
netMesh.position.set(6.7, 3.05, 0.457);
trajScene.add(netMesh);
// Ball
trajBallMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.15, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xffff00 })
);
trajBallMesh.visible = false;
trajScene.add(trajBallMesh);
// Trajectory line
trajLine = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: 0xff6600, linewidth: 2 })
);
trajScene.add(trajLine);
// Landing marker
window.landingMarker = new THREE.Mesh(
new THREE.RingGeometry(0.1, 0.2, 32),
new THREE.MeshBasicMaterial({ color: 0xff0000, side: THREE.DoubleSide, transparent: true, opacity: 0.6 })
);
window.landingMarker.position.z = 0.01;
window.landingMarker.visible = false;
trajScene.add(window.landingMarker);
// Shadow
window.ballShadow = new THREE.Mesh(
new THREE.CircleGeometry(0.1, 16),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.3 })
);
window.ballShadow.position.z = 0.005;
window.ballShadow.visible = false;
trajScene.add(window.ballShadow);
trajScene.add(new THREE.AmbientLight(0xffffff, 0.8));
// Physical camera marker: 1m from net, center, 1m height
addStereocameras(trajScene, getCamParams());
function animateTraj() {
requestAnimationFrame(animateTraj);
controls.update();
trajRenderer.render(trajScene, trajCamera);
}
animateTraj();
window.addEventListener('resize', function() {
var w2 = container.clientWidth;
var h2 = container.clientHeight;
trajCamera.aspect = w2 / h2;
trajCamera.updateProjectionMatrix();
trajRenderer.setSize(w2, h2);
});
}
// ===================== Stereo camera rig with court coverage =====================
// Track camera overlay objects for live update
var camOverlayObjects = [];
function addStereocameras(scene, params) {
params = params || {};
var baseX = 6.7;
var baseY = -(params.posY || 1);
var baseZ = params.posZ || 1;
var stereoGap = (params.stereo || 6) / 100;
var camAngle = params.angle || 15;
var hfov = params.hfov || 128;
// Remove old overlay objects
camOverlayObjects.forEach(function(obj) { scene.remove(obj); });
camOverlayObjects = [];
function addObj(obj) {
scene.add(obj);
camOverlayObjects.push(obj);
}
var cam0x = baseX - stereoGap / 2;
var cam1x = baseX + stereoGap / 2;
// Small camera bodies
function drawCamBody(cx, color) {
var body = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 0.08, 0.08),
new THREE.MeshBasicMaterial({ color: color })
);
body.position.set(cx, baseY, baseZ);
addObj(body);
}
drawCamBody(cam0x, 0x44aaff);
drawCamBody(cam1x, 0xff44aa);
// Pole
var poleGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(baseX, baseY, 0),
new THREE.Vector3(baseX, baseY, baseZ)
]);
addObj(new THREE.Line(poleGeo, new THREE.LineBasicMaterial({ color: 0x666666 })));
// Court boundaries for clipping
var courtMinX = 0, courtMaxX = 13.4, courtMinY = 0, courtMaxY = 6.1;
var deg2rad = Math.PI / 180;
var halfFov = hfov / 2;
function drawCoverage(cx, angleDeg, color) {
var centerAngle = 90 + angleDeg;
var leftAngle = centerAngle + halfFov;
var rightAngle = centerAngle - halfFov;
var ox = cx, oy = baseY;
// Cast ray, return farthest point on court boundary
function castRay(aDeg) {
var rad = aDeg * deg2rad;
var dx = Math.cos(rad);
var dy = Math.sin(rad);
if (dy <= 0) return null;
// t where ray enters court (Y=0)
var tEnter = (courtMinY - oy) / dy;
// t where ray exits court
var tExit = (courtMaxY - oy) / dy;
// clip X bounds
if (dx > 1e-9) tExit = Math.min(tExit, (courtMaxX - ox) / dx);
else if (dx < -1e-9) tExit = Math.min(tExit, (courtMinX - ox) / dx);
if (tExit <= tEnter) return null;
var px = ox + dx * tExit;
var py = oy + dy * tExit;
px = Math.max(courtMinX, Math.min(courtMaxX, px));
py = Math.max(courtMinY, Math.min(courtMaxY, py));
return new THREE.Vector3(px, py, 0.015);
}
// Camera actual position (behind court at Y=-1)
var camOnCourt = new THREE.Vector3(cx, oy, 0.015);
// Far edge points
var farPoints = [];
var steps = 64;
for (var i = 0; i <= steps; i++) {
var a = rightAngle + (leftAngle - rightAngle) * (i / steps);
var pt = castRay(a);
if (pt) farPoints.push(pt);
}
if (farPoints.length < 2) return;
// Draw triangle fan from camera court position to far edge
var mat = new THREE.MeshBasicMaterial({
color: color, transparent: true, opacity: 0.35,
side: THREE.DoubleSide, depthWrite: false
});
for (var i = 0; i < farPoints.length - 1; i++) {
var triGeo = new THREE.BufferGeometry().setFromPoints([
camOnCourt, farPoints[i], farPoints[i + 1]
]);
addObj(new THREE.Mesh(triGeo, mat));
}
}
drawCoverage(cam0x, -camAngle, 0x4488ff); // blue
drawCoverage(cam1x, camAngle, 0xff44aa); // pink
// overlap blends to purple naturally
}
// ===================== Draw court lines =====================
function drawCourtLines(scene) {
var mat = new THREE.LineBasicMaterial({ color: 0xffffff });
function addLine(x1, y1, x2, y2) {
var geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(x1, y1, 0.05),
new THREE.Vector3(x2, y2, 0.05)
]);
scene.add(new THREE.Line(geo, mat));
}
// Outer boundaries
addLine(0, 0, 13.4, 0);
addLine(13.4, 0, 13.4, 6.1);
addLine(13.4, 6.1, 0, 6.1);
addLine(0, 6.1, 0, 0);
// Net / center line
addLine(6.7, 0, 6.7, 6.1);
// Kitchen lines (NVZ)
addLine(6.7 - 2.13, 0, 6.7 - 2.13, 6.1);
addLine(6.7 + 2.13, 0, 6.7 + 2.13, 6.1);
// Center service lines
addLine(0, 3.05, 6.7 - 2.13, 3.05);
addLine(6.7 + 2.13, 3.05, 13.4, 3.05);
}
// ===================== Trajectory data polling =====================
setInterval(function() {
if (activeTab !== 'trajectory' && activeTab !== 'court') return;
fetch('/api/trajectory').then(function(r) { return r.json(); }).then(function(data) {
var points = data.points || [];
var speed = data.speed;
var landing = data.landing;
document.getElementById('ball-speed').textContent =
speed !== null ? (speed * 3.6).toFixed(1) + ' km/h' : '--';
if (points.length > 0) {
var last = points[points.length - 1];
document.getElementById('ball-height').textContent =
last.z !== null ? last.z.toFixed(2) + ' m' : '--';
}
document.getElementById('ball-landing').textContent =
landing ? '(' + landing.x.toFixed(1) + ', ' + landing.y.toFixed(1) + ') ' + landing.t.toFixed(2) + 's' : '--';
if (points.length > 0) {
var last = points[points.length - 1];
if (courtBallMesh) {
courtBallMesh.visible = true;
courtBallMesh.position.set(last.x, last.y, last.z || 0.15);
}
if (trajBallMesh) {
trajBallMesh.visible = true;
trajBallMesh.position.set(last.x, last.y, last.z || 0.15);
if (window.ballShadow) {
window.ballShadow.visible = true;
window.ballShadow.position.set(last.x, last.y, 0.005);
}
var linePoints = points.map(function(p) {
return new THREE.Vector3(p.x, p.y, p.z || 0);
});
if (linePoints.length > 1) {
trajLine.geometry.dispose();
trajLine.geometry = new THREE.BufferGeometry().setFromPoints(linePoints);
}
}
if (landing && window.landingMarker) {
window.landingMarker.visible = true;
window.landingMarker.position.set(landing.x, landing.y, 0.01);
} else if (window.landingMarker) {
window.landingMarker.visible = false;
}
}
}).catch(function() {});
}, 100);
// ===================== VAR panel polling =====================
var varTimerInterval = null;
var lastVarTimestamp = null;
function updateVarTimer() {
if (!lastVarTimestamp) return;
var ago = (Date.now() / 1000) - lastVarTimestamp;
var el = document.getElementById('varTimer');
if (ago < 60) {
el.textContent = ago.toFixed(0) + 's ago';
} else if (ago < 3600) {
el.textContent = Math.floor(ago / 60) + 'm ' + Math.floor(ago % 60) + 's ago';
} else {
el.textContent = Math.floor(ago / 3600) + 'h ago';
}
// Red dot stays active for 30 seconds
var dot = document.getElementById('varDot');
var label = document.getElementById('varLabel');
if (ago < 30) {
dot.classList.add('active');
label.classList.add('active');
label.textContent = 'VAR ACTIVE';
} else {
dot.classList.remove('active');
label.classList.remove('active');
label.textContent = 'VAR';
}
}
setInterval(function() {
fetch('/api/var/last')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data) return;
var event = data.event;
lastVarTimestamp = event.timestamp;
document.getElementById('varLine').textContent =
'Line: ' + event.line;
document.getElementById('varDist').textContent =
'Distance: ' + (event.distance_m * 100).toFixed(0) + ' cm';
updateVarTimer();
// Show snapshot
if (data.snapshot_b64) {
var img = document.getElementById('varSnapshot');
if (img) {
img.src = 'data:image/jpeg;base64,' + data.snapshot_b64;
img.style.display = 'block';
}
var empty = document.getElementById('varSnapshotEmpty');
if (empty) empty.style.display = 'none';
}
})
.catch(function() {});
}, 1000);
// Update timer every second
setInterval(updateVarTimer, 1000);
// ===================== Event banner =====================
var lastEventCount = 0;
setInterval(function() {
fetch('/api/events').then(function(r) { return r.json(); }).then(function(events) {
document.getElementById('event-count').textContent = events.length;
if (events.length > lastEventCount) {
var banner = document.getElementById('eventBanner');
var latest = events[events.length - 1];
banner.textContent = 'VAR: Close Call - ' + latest.line +
' (' + (latest.distance_m * 100).toFixed(0) + 'cm)';
banner.classList.add('show');
setTimeout(function() { banner.classList.remove('show'); }, 5000);
lastEventCount = events.length;
}
}).catch(function() {});
}, 1000);
// ===================== Camera params live update =====================
function getCamParams() {
return {
posY: parseFloat(document.getElementById('paramPosY').textContent) || 1,
posZ: parseFloat(document.getElementById('paramPosZ').textContent) || 1,
stereo: parseFloat(document.getElementById('paramStereo').textContent) || 6,
angle: parseFloat(document.getElementById('paramAngle').textContent) || 15,
hfov: parseFloat(document.getElementById('paramHfov').textContent) || 128
};
}
function setCamParams(p) {
document.getElementById('paramPosY').textContent = p.posY;
document.getElementById('paramPosZ').textContent = p.posZ;
document.getElementById('paramStereo').textContent = p.stereo;
document.getElementById('paramAngle').textContent = p.angle;
document.getElementById('paramHfov').textContent = p.hfov;
if (courtSceneInitialized) addStereocameras(courtScene, p);
if (trajSceneInitialized) addStereocameras(trajScene, p);
}
</script>
</body>
</html>