From 2dcb30a6de56b26552ac83215819a54a7f917f61 Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 16:34:04 +0700 Subject: [PATCH] feat(adr-105): hide pose canvas in Docker SPA when no model is loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoseDetectionCanvas polls /api/v1/pose/stats every 30 s. When model_loaded === false (the default — no trained pose model present), the canvas is hidden and a "No trained pose model loaded" overlay explains why, pointing the operator at the Sensing / Hardware tabs for the channels that are still active. renderPoseData() also short-circuits on modelLoaded !== true so any WS frames that slip through during the poll interval can't paint a misleading skeleton. Closes the last Open Item in ADR-105. Co-Authored-By: claude-flow --- ui/components/PoseDetectionCanvas.js | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/ui/components/PoseDetectionCanvas.js b/ui/components/PoseDetectionCanvas.js index 172cf985..cd7b564c 100644 --- a/ui/components/PoseDetectionCanvas.js +++ b/ui/components/PoseDetectionCanvas.js @@ -51,6 +51,17 @@ export class PoseDetectionCanvas { this.showTrail = false; this.maxTrailLength = 10; + // ADR-105 / ADR-113: model-load gating. The canvas refuses to draw + // skeletons until /api/v1/pose/stats reports model_loaded === true, + // so an empty/zero-confidence keypoint stream from a model-less + // server doesn't paint a misleading "phantom" pose. + // + // null = "haven't asked yet" (treated as not-loaded for rendering). + this.modelLoaded = null; + this.modelStatusUrl = options.modelStatusUrl || '/api/v1/pose/stats'; + this.modelStatusPollMs = options.modelStatusPollMs || 30000; + this.modelStatusTimer = null; + // Initialize component this.initializeComponent(); } @@ -79,9 +90,79 @@ export class PoseDetectionCanvas { // Set up pose service subscription this.setupPoseServiceSubscription(); + // ADR-105: poll model_loaded so we can hide the canvas when no + // trained pose model is on the server. + this.checkModelStatus(); + this.modelStatusTimer = setInterval( + () => this.checkModelStatus(), + this.modelStatusPollMs + ); + this.logger.info('PoseDetectionCanvas component initialized successfully'); } + /** + * Fetch `/api/v1/pose/stats` and update `this.modelLoaded`. On the + * leading-edge transitions (null → false, true → false) we hide the + * pose canvas and overlay a "No model loaded" notice so the operator + * isn't fooled by an empty skeleton renderer. + */ + async checkModelStatus() { + try { + const resp = await fetch(this.modelStatusUrl, { cache: 'no-store' }); + if (!resp.ok) { + // Server reachable but not surfacing pose stats — be safe. + this.setModelLoaded(false, 'pose-stats endpoint error'); + return; + } + const json = await resp.json(); + const loaded = json && json.model_loaded === true; + this.setModelLoaded(loaded, null); + } catch (e) { + // Network blip — don't flip-flop the UI on a transient failure. + this.logger.debug('model-status poll failed', { err: e.message }); + } + } + + setModelLoaded(loaded, errOrNull) { + if (this.modelLoaded === loaded) return; + this.modelLoaded = loaded; + this.logger.info('model-loaded state changed', { loaded, note: errOrNull }); + this.updateCanvasVisibility(); + } + + updateCanvasVisibility() { + if (!this.canvas) return; + const wrap = this.canvas.parentElement; // .pose-canvas-container + const overlayId = `model-overlay-${this.containerId}`; + let overlay = document.getElementById(overlayId); + if (this.modelLoaded === true) { + this.canvas.style.visibility = 'visible'; + if (overlay) overlay.style.display = 'none'; + return; + } + // No model — hide the canvas and show a clear notice. + this.canvas.style.visibility = 'hidden'; + if (!overlay && wrap) { + overlay = document.createElement('div'); + overlay.id = overlayId; + overlay.className = 'pose-model-missing'; + overlay.style.cssText = + 'position:absolute;inset:0;display:flex;align-items:center;' + + 'justify-content:center;color:#888;font-family:JetBrains Mono,monospace;' + + 'font-size:13px;text-align:center;padding:20px;background:#0d1117;'; + overlay.innerHTML = + 'No trained pose model loaded.
' + + '' + + 'Pose rendering disabled — sensing channels still active in ' + + 'the Sensing / Hardware tabs (ADR-105).'; + wrap.style.position = 'relative'; + wrap.appendChild(overlay); + } else if (overlay) { + overlay.style.display = 'flex'; + } + } + createDOMStructure() { this.container.innerHTML = `
@@ -516,6 +597,13 @@ export class PoseDetectionCanvas { if (!this.renderer || !this.state.isActive) { return; } + // ADR-105: refuse to paint anything when the server has no trained + // pose model — empty/zero-confidence keypoints would otherwise show + // up as a misleading skeleton. The overlay from + // updateCanvasVisibility() already tells the operator why. + if (this.modelLoaded !== true) { + return; + } try { // Render trail before the current frame if enabled @@ -1535,6 +1623,12 @@ export class PoseDetectionCanvas { this.unsubscribeFunctions.forEach(unsubscribe => unsubscribe()); this.unsubscribeFunctions = []; + // ADR-105: stop the model-status poll. + if (this.modelStatusTimer) { + clearInterval(this.modelStatusTimer); + this.modelStatusTimer = null; + } + // Clean up resize observer if (this.resizeObserver) { this.resizeObserver.disconnect();