Zhen Ye Claude Opus 4.6 (1M context) commited on
Commit
2c64c05
·
1 Parent(s): 100e159

refactor: remove /app frontend, consolidate on demo as sole UI

Browse files

The demo (/) is the primary UI with command bar, state machine, event
log, and reasoning trace. The /app frontend was a secondary analyst tool
with less operational capability. All Tripo3D and inspection features
are already ported to the demo.

- Remove frontend/ directory and /app mount from app.py
- Update CLAUDE.md to reflect demo/ as the sole frontend
- Update no-cache middleware to target /demo instead of /app

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CLAUDE.md CHANGED
@@ -92,24 +92,22 @@ Frontend (index.html) → POST /detect/async → background task → MJPEG strea
92
  - **`background.py`** — `process_video_async()` coroutine dispatches to the right inference function
93
  - **`streaming.py`** — MJPEG frame queue + `asyncio.Event` publisher; `publish_frame()` resizes to 640px
94
 
95
- ### Frontend (frontend/)
96
 
97
- Single-page app served at `/app`. No build step. Uses `window.APP` global namespace.
98
 
99
- **Script modules (load order matters):**
100
- - `init.js` → bootstraps `window.APP` namespace
101
- - `core/config.js` → backend URL, tracking constants
102
- - `core/state.js` → all client state (video, job, tracks, UI)
103
- - `core/video.js` → video load/unload, blob lifecycle, depth toggle
104
- - `core/tracker.js` → client-side IoU + velocity tracker
105
- - `core/timeline.js` → canvas heatmap timeline bar
106
- - `api/client.js` → `hfDetectAsync()`, `pollAsyncJob()`, `cancelBackendJob()`
107
- - `ui/overlays.js` → canvas bounding box rendering
108
- - `ui/cards.js` → live track card panel
109
- - `ui/logging.js` → system log, status indicators
110
- - `main.js` → event wiring, app entry point
111
 
112
- The frontend infers `mode` from `data-kind` attribute on the `<select id="detectorSelect">` options.
113
 
114
  ## Models
115
 
@@ -144,7 +142,7 @@ Single entry: key `depth` → `DepthAnythingV2Estimator`. Optional, enabled via
144
  1. Create class in `models/detectors/` implementing `ObjectDetector.predict()` → `DetectionResult`
145
  2. If weights need downloading, add `ensure_weights()` classmethod for thread-safe prefetch
146
  3. Register in `models/model_loader.py` `_REGISTRY`
147
- 4. Add `<option>` to `frontend/index.html` `#detectorSelect` with appropriate `data-kind`
148
 
149
  ## Key Patterns
150
 
 
92
  - **`background.py`** — `process_video_async()` coroutine dispatches to the right inference function
93
  - **`streaming.py`** — MJPEG frame queue + `asyncio.Event` publisher; `publish_frame()` resizes to 640px
94
 
95
+ ### Frontend (demo/)
96
 
97
+ Single-page command center UI served at `/` (mounted at `/demo`). No build step. Uses `window.ISR` global namespace.
98
 
99
+ **Key scripts:**
100
+ - `init.js` → bootstraps `window.ISR`, wires UI, initializes state machine
101
+ - `state-machine.js` → explicit FSM for UI flow (idle → detecting → playing → inspect)
102
+ - `api.js` → all backend API calls (`startDetection`, `fetchTracks`, `fetchPointCloud`, etc.)
103
+ - `real-backend.js` → streaming + polling + prefetch logic for live detection jobs
104
+ - `inspect.js` → 4-quadrant inspection panel (seg, edge, depth, 3D) with Tripo3D support
105
+ - `render.js` → canvas overlays for bounding boxes and tracks
106
+ - `ui.js` → panel layout, drawer tabs, command bar
107
+ - `analysis.js` → track analysis and timeline rendering
108
+ - `helpers.js` → viridis colormap, Sobel filter, RLE decode, utility functions
 
 
109
 
110
+ The frontend infers `mode` from the detector select element's `data-kind` attribute.
111
 
112
  ## Models
113
 
 
142
  1. Create class in `models/detectors/` implementing `ObjectDetector.predict()` → `DetectionResult`
143
  2. If weights need downloading, add `ensure_weights()` classmethod for thread-safe prefetch
144
  3. Register in `models/model_loader.py` `_REGISTRY`
145
+ 4. Add `<option>` to `demo/index.html` `#detectorSelect` with appropriate `data-kind`
146
 
147
  ## Key Patterns
148
 
app.py CHANGED
@@ -101,16 +101,13 @@ async def add_no_cache_header(request: Request, call_next):
101
  """Ensure frontend assets are not cached by the browser (important for HF Spaces updates)."""
102
  response = await call_next(request)
103
  # Apply to all static files and the root page
104
- if request.url.path.startswith("/app") or request.url.path == "/":
105
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
106
  response.headers["Pragma"] = "no-cache"
107
  response.headers["Expires"] = "0"
108
  return response
109
 
110
  app.mount("/demo", StaticFiles(directory="demo", html=True), name="demo")
111
- _FRONTEND_DIR = Path(__file__).with_name("frontend")
112
- if _FRONTEND_DIR.exists():
113
- app.mount("/app", StaticFiles(directory=_FRONTEND_DIR, html=True), name="frontend")
114
 
115
  # Valid detection modes
116
  VALID_MODES = {"object_detection", "segmentation", "drone_detection"}
 
101
  """Ensure frontend assets are not cached by the browser (important for HF Spaces updates)."""
102
  response = await call_next(request)
103
  # Apply to all static files and the root page
104
+ if request.url.path.startswith("/demo") or request.url.path == "/":
105
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
106
  response.headers["Pragma"] = "no-cache"
107
  response.headers["Expires"] = "0"
108
  return response
109
 
110
  app.mount("/demo", StaticFiles(directory="demo", html=True), name="demo")
 
 
 
111
 
112
  # Valid detection modes
113
  VALID_MODES = {"object_detection", "segmentation", "drone_detection"}
demo/js/inspect.js CHANGED
@@ -552,9 +552,15 @@ async function renderRealSegmentation(jobId, frameIdx, trackId) {
552
  const dpr = window.devicePixelRatio || 1;
553
  canvas.width = canvas.offsetWidth * dpr;
554
  canvas.height = canvas.offsetHeight * dpr;
 
 
 
 
 
555
  try {
556
  // Fetch the cropped frame image first (for overlay rendering)
557
  const frameBlob = await ISR.fetchFrame(jobId, frameIdx, trackId);
 
558
  const maskData = await ISR.fetchMask(jobId, frameIdx, trackId);
559
  if (!maskData) { showQuadPlaceholder(ctx, canvas, 'MASK UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
560
 
@@ -638,6 +644,10 @@ async function renderRealEdge(jobId, frameIdx, trackId) {
638
  const dpr = window.devicePixelRatio || 1;
639
  canvas.width = canvas.offsetWidth * dpr;
640
  canvas.height = canvas.offsetHeight * dpr;
 
 
 
 
641
  try {
642
  const blob = await ISR.fetchFrame(jobId, frameIdx, trackId);
643
  if (!blob) { showQuadPlaceholder(ctx, canvas, 'FRAME UNAVAILABLE'); return; }
@@ -658,6 +668,10 @@ async function renderRealDepth(jobId, frameIdx, trackId) {
658
  const dpr = window.devicePixelRatio || 1;
659
  canvas.width = canvas.offsetWidth * dpr;
660
  canvas.height = canvas.offsetHeight * dpr;
 
 
 
 
661
  try {
662
  const depthData = await ISR.fetchDepth(jobId, frameIdx, trackId);
663
  if (!depthData || !depthData.data) { showQuadPlaceholder(ctx, canvas, 'DEPTH UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
 
552
  const dpr = window.devicePixelRatio || 1;
553
  canvas.width = canvas.offsetWidth * dpr;
554
  canvas.height = canvas.offsetHeight * dpr;
555
+ if (canvas.width === 0 || canvas.height === 0) {
556
+ // Layout not ready — retry after next frame
557
+ requestAnimationFrame(() => renderRealSegmentation(jobId, frameIdx, trackId));
558
+ return;
559
+ }
560
  try {
561
  // Fetch the cropped frame image first (for overlay rendering)
562
  const frameBlob = await ISR.fetchFrame(jobId, frameIdx, trackId);
563
+ if (!frameBlob) { showQuadPlaceholder(ctx, canvas, 'FRAME UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
564
  const maskData = await ISR.fetchMask(jobId, frameIdx, trackId);
565
  if (!maskData) { showQuadPlaceholder(ctx, canvas, 'MASK UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
566
 
 
644
  const dpr = window.devicePixelRatio || 1;
645
  canvas.width = canvas.offsetWidth * dpr;
646
  canvas.height = canvas.offsetHeight * dpr;
647
+ if (canvas.width === 0 || canvas.height === 0) {
648
+ requestAnimationFrame(() => renderRealEdge(jobId, frameIdx, trackId));
649
+ return;
650
+ }
651
  try {
652
  const blob = await ISR.fetchFrame(jobId, frameIdx, trackId);
653
  if (!blob) { showQuadPlaceholder(ctx, canvas, 'FRAME UNAVAILABLE'); return; }
 
668
  const dpr = window.devicePixelRatio || 1;
669
  canvas.width = canvas.offsetWidth * dpr;
670
  canvas.height = canvas.offsetHeight * dpr;
671
+ if (canvas.width === 0 || canvas.height === 0) {
672
+ requestAnimationFrame(() => renderRealDepth(jobId, frameIdx, trackId));
673
+ return;
674
+ }
675
  try {
676
  const depthData = await ISR.fetchDepth(jobId, frameIdx, trackId);
677
  if (!depthData || !depthData.data) { showQuadPlaceholder(ctx, canvas, 'DEPTH UNAVAILABLE'); if (metric) metric.textContent = '\u2014'; return; }
frontend/index.html DELETED
@@ -1,310 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
-
4
- <head>
5
- <meta charset="UTF-8" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <link rel="stylesheet" href="style.css?v=20260323">
8
- <title>ISR Analytics</title>
9
- </head>
10
-
11
- <body>
12
- <!-- Ambient animated background -->
13
- <div id="ambientBg">
14
- <div class="orb orb-1"></div>
15
- <div class="orb orb-2"></div>
16
- <div class="orb orb-3"></div>
17
- </div>
18
-
19
- <div id="app">
20
- <header>
21
- <div class="brand">
22
- <div class="logo" aria-hidden="true"></div>
23
- <div>
24
- <h1>ISR Analytics</h1>
25
- <div class="sub">Real-time video intelligence</div>
26
- </div>
27
- </div>
28
- <div class="status-row">
29
- <div class="pill">
30
- <span class="dot" id="sys-dot"></span>
31
- <span id="sys-status">Standby</span>
32
- </div>
33
- <div class="pill">
34
- <span class="kbd">Live</span>
35
- <span>Continuous tracking</span>
36
- </div>
37
- </div>
38
- </header>
39
-
40
- <div class="workspace">
41
- <aside>
42
- <div class="card">
43
- <h2>Input</h2>
44
- <div class="hint">Upload a video to begin analysis.</div>
45
-
46
- <div class="row mt-md">
47
- <label for="videoFile">Video file</label>
48
- <span class="badge"><span id="videoMeta">No file</span></span>
49
- </div>
50
- <input id="videoFile" type="file" accept="video/*" />
51
-
52
- <div class="mt-md">
53
- <label>Detection Query (optional)</label>
54
- <textarea id="missionText" rows="3"
55
- placeholder="e.g., person, vehicle, hazard"></textarea>
56
-
57
- <div class="hint mt-sm">
58
- Specify object classes to filter detection. Leave blank to detect all objects.
59
- <div class="mini mt-xs" id="hfBackendStatus">Backend: Standby</div>
60
- </div>
61
- </div>
62
-
63
- <div class="btnrow">
64
- <button id="btnLoadSample" class="btn secondary" title="Load a sample video" disabled>Load
65
- Sample</button>
66
- <button id="btnEject" class="btn danger" title="Unload video">Eject</button>
67
- </div>
68
-
69
- <div class="grid2">
70
- <div>
71
- <label>Detector</label>
72
- <select id="detectorSelect">
73
- <optgroup label="Object Detection Models">
74
- <option value="yolo11" data-kind="object" selected>Lite</option>
75
- <option value="detr_resnet50" data-kind="object">Big</option>
76
- <option value="grounding_dino" data-kind="object">Large</option>
77
- </optgroup>
78
- <optgroup label="Segmentation Models">
79
- <option value="GSAM2-L" data-kind="segmentation">GSAM2-L</option>
80
- <option value="GSAM2-B" data-kind="segmentation">GSAM2-B</option>
81
- <option value="GSAM2-S" data-kind="segmentation">GSAM2-S</option>
82
- <option value="YSAM2-L" data-kind="segmentation">YSAM2-L (Fast)</option>
83
- <option value="YSAM2-B" data-kind="segmentation">YSAM2-B (Fast)</option>
84
- <option value="YSAM2-S" data-kind="segmentation">YSAM2-S (Fast)</option>
85
- </optgroup>
86
- <optgroup label="Drone Detection Models">
87
- <option value="yolov8_visdrone" data-kind="drone">VisDrone (YOLOv8)</option>
88
- </optgroup>
89
- </select>
90
- </div>
91
- <div>
92
- <label>Tracking</label>
93
- <select id="trackerSelect">
94
- <option value="iou">IOU + velocity (built-in)</option>
95
- <option value="external">External hook (user API)</option>
96
- </select>
97
- </div>
98
-
99
- <label class="checkbox-row" for="enableDepthToggle" style="display:none">
100
- <input type="checkbox" id="enableDepthToggle">
101
- <span>Enable Depth Map</span>
102
- </label>
103
- <label class="checkbox-row" for="enableStreamToggle" style="margin-top: 4px;">
104
- <input type="checkbox" id="enableStreamToggle" checked>
105
- <span>Enable Stream Processing</span>
106
- </label>
107
- </div>
108
-
109
- </div>
110
-
111
- <div class="card" style="flex:1; min-height:0">
112
- <h2>System Log</h2>
113
- <div class="log" id="sysLog"></div>
114
- </div>
115
- </aside>
116
-
117
- <main>
118
- <section class="tab active" id="tab-engage">
119
- <div class="engage-grid">
120
- <div class="panel">
121
- <h3>
122
- <span>Video Analysis</span>
123
- <div style="display: flex; gap: 8px; align-items: center;">
124
- <button class="collapse-btn" id="btnToggleSidebar">Hide Panel</button>
125
- <span class="rightnote" id="engageNote">Awaiting video</span>
126
- </div>
127
- </h3>
128
-
129
- <div class="viewbox" style="min-height: 420px;">
130
- <video id="videoEngage" playsinline muted></video>
131
-
132
- <!-- Progress ring overlay (shown during detection) -->
133
- <div class="progress-ring-wrap" id="progressRing">
134
- <svg viewBox="0 0 90 90">
135
- <circle class="progress-ring-bg" cx="45" cy="45" r="42" />
136
- <circle class="progress-ring-fg" id="progressRingFg" cx="45" cy="45" r="42" />
137
- </svg>
138
- <div class="progress-ring-text" id="progressRingText">Detecting...</div>
139
- </div>
140
-
141
- <div class="watermark">ISR Analytics</div>
142
- <div class="empty" id="engageEmpty">
143
- <div class="big">No video loaded</div>
144
- <div class="small">Upload a video file, then run <b>Detect</b> to identify objects. Use <b>Track</b> to follow them in real time.</div>
145
- </div>
146
- </div>
147
-
148
- <div id="timelineWrap" class="timeline-wrap">
149
- <canvas id="timelineBar"></canvas>
150
- </div>
151
-
152
- <div class="btnrow mt-md">
153
- <button id="btnReason" class="btn">Detect</button>
154
- <button id="btnCancelReason" class="btn danger" style="display: none;">Cancel</button>
155
- <button id="btnEngage" class="btn">Track</button>
156
- <button id="btnPause" class="btn secondary">Pause</button>
157
- <button id="btnReset" class="btn secondary">Reset</button>
158
- </div>
159
-
160
- <div class="strip mt-md">
161
- <span class="chip" id="chipTracks">TRACKS:0</span>
162
- <span class="chip" id="chipFeed" title="Toggle raw vs HF-processed feed (if available)">FEED:RAW</span>
163
- <span class="chip" id="chipDepth" title="Toggle depth view (if available)" style="display:none">VIEW:DEFAULT</span>
164
- </div>
165
- </div>
166
-
167
- <div class="engage-right">
168
- <div class="panel" style="flex:1; min-height:0">
169
- <h3>
170
- <span>Detected Objects</span>
171
- <span class="rightnote" id="liveStamp"></span>
172
- </h3>
173
- <div class="list" id="trackList" style="max-height:360px; overflow-y:auto"></div>
174
- </div>
175
- </div>
176
-
177
- <div class="panel panel-inspection" id="inspectionPanel" style="display:none">
178
- <div class="inspection-header">
179
- <div class="inspection-header-left">
180
- <span class="inspection-obj-name" id="inspectionObjName">--</span>
181
- <span class="inspection-conf" id="inspectionConf">--</span>
182
- <span class="inspection-status pending" id="inspectionStatus">PENDING</span>
183
- </div>
184
- <div class="inspection-header-right">
185
- <span class="inspection-frame" id="inspectionFrame">FRM -- / --</span>
186
- <button class="collapse-btn" id="btnCloseInspection">CLOSE</button>
187
- </div>
188
- </div>
189
-
190
- <div class="inspection-quad" id="inspectionQuad">
191
- <div class="inspection-quadrant" data-mode="seg" id="quadSeg">
192
- <div class="quad-label"><span class="quad-dot quad-dot-seg"></span>SEGMENTATION</div>
193
- <canvas class="quad-canvas" id="quadCanvasSeg"></canvas>
194
- <div class="quad-metric" id="quadMetricSeg"></div>
195
- <div class="quad-loading" style="display:none"><div class="inspection-spinner"></div></div>
196
- <div class="quad-error" style="display:none"></div>
197
- </div>
198
- <div class="inspection-quadrant" data-mode="edge" id="quadEdge">
199
- <div class="quad-label"><span class="quad-dot quad-dot-edge"></span>EDGE</div>
200
- <canvas class="quad-canvas" id="quadCanvasEdge"></canvas>
201
- <div class="quad-metric" id="quadMetricEdge">SOBEL</div>
202
- <div class="quad-error" style="display:none"></div>
203
- </div>
204
- <div class="inspection-quadrant" data-mode="depth" id="quadDepth">
205
- <div class="quad-label"><span class="quad-dot quad-dot-depth"></span>DEPTH</div>
206
- <canvas class="quad-canvas" id="quadCanvasDepth"></canvas>
207
- <div class="quad-metric" id="quadMetricDepth"></div>
208
- <div class="quad-loading" style="display:none"><div class="inspection-spinner"></div></div>
209
- <div class="quad-error" style="display:none"></div>
210
- </div>
211
- <div class="inspection-quadrant" data-mode="3d" id="quad3d">
212
- <div class="quad-label"><span class="quad-dot quad-dot-3d"></span>3D MESH</div>
213
- <div class="quad-3d-container" id="quad3dContainer"></div>
214
- <div class="quad-metric" id="quadMetric3d"></div>
215
- <div class="quad-loading" style="display:none"><div class="inspection-spinner"></div></div>
216
- <div class="quad-error" style="display:none"></div>
217
- </div>
218
- </div>
219
-
220
- <div class="inspection-metrics" id="inspectionMetrics"></div>
221
- <div class="interpretability-graph" id="interpretabilityGraph"></div>
222
-
223
- <div class="inspection-empty" id="inspectionEmpty">
224
- Select an object from the track cards or click a bounding box to inspect it.
225
- </div>
226
- </div>
227
-
228
- <div class="panel panel-chat" id="chatPanel">
229
- <h3>
230
- <span>AI Assistant</span>
231
- <div style="display: flex; gap: 8px; align-items: center;">
232
- <button class="collapse-btn" id="chatClear">Clear</button>
233
- </div>
234
- </h3>
235
- <div class="chat-context-header" id="chatContextHeader" style="display:none;">
236
- <span class="chat-context-label">CONTEXT</span>
237
- <div class="chat-context-chips" id="chatContextChips"></div>
238
- </div>
239
- <div class="chat-suggestions" id="chatSuggestions" style="display:none;"></div>
240
- <div class="chat-container">
241
- <div class="chat-messages" id="chatMessages"></div>
242
- <div class="chat-input-row">
243
- <input type="text" id="chatInput" placeholder="Select a track to start..." />
244
- <button class="btn" id="chatSend">Send</button>
245
- </div>
246
- </div>
247
- </div>
248
- </div><!-- engage-grid -->
249
- </section>
250
-
251
- </div>
252
-
253
- <footer>
254
- <div class="footer-left">ISR Analytics Platform</div>
255
- <div class="stat-bar" id="statBar">
256
- <div class="stat-item">
257
- <span class="stat-label">Objects</span>
258
- <span class="stat-value" id="statTracks">0</span>
259
- </div>
260
- <div class="stat-item">
261
- <span class="stat-label">Model</span>
262
- <span class="stat-value" id="statModel">YOLO11</span>
263
- </div>
264
- <div class="stat-item">
265
- <span class="stat-label">Status</span>
266
- <span class="stat-value" id="statStatus">Ready</span>
267
- </div>
268
- </div>
269
- </footer>
270
-
271
- </div>
272
-
273
- <!-- Toast notification container -->
274
- <div class="toast-container" id="toastContainer"></div>
275
-
276
- <script>
277
- window.API_CONFIG = {
278
- BACKEND_BASE: "https://biaslab2025-isr.hf.space"
279
- };
280
- </script>
281
- <script src="./js/init.js?v=20260318"></script>
282
- <script src="./js/core/config.js?v=20260318"></script>
283
- <script src="./js/core/utils.js?v=20260318"></script>
284
- <script src="./js/core/state.js?v=20260318"></script>
285
- <script src="./js/core/physics.js?v=20260318"></script>
286
- <script src="./js/core/video.js?v=20260318"></script>
287
- <script src="./js/ui/logging.js?v=20260318"></script>
288
- <script src="./js/core/gptMapping.js?v=20260318"></script>
289
- <script src="./js/core/tracker.js?v=20260318"></script>
290
- <script src="./js/core/timeline.js?v=20260318"></script>
291
- <script src="./js/api/client.js?v=20260318"></script>
292
- <script src="./js/api/inspection-api.js?v=20260318"></script>
293
- <script src="./js/ui/overlays.js?v=20260318"></script>
294
- <script src="./js/ui/cards.js?v=20260318"></script>
295
- <script src="./js/ui/chat.js?v=20260318"></script>
296
- <script src="./vendor/d3-hierarchy.v3.min.js?v=20260323"></script>
297
- <script src="./vendor/d3-path.v3.min.js?v=20260323"></script>
298
- <script src="./vendor/d3-shape.v3.min.js?v=20260323"></script>
299
- <script src="./js/ui/inspection.js?v=20260318"></script>
300
- <script src="./js/ui/inspection-renders.js?v=20260318"></script>
301
- <script src="./js/ui/inspection-3d.js?v=20260318"></script>
302
- <script src="./js/ui/explainability.js?v=20260323"></script>
303
- <script src="./js/ui/cursor.js?v=20260318"></script>
304
- <script src="./js/ui/animations.js?v=20260318"></script>
305
- <script src="./js/core/demo.js?v=20260318"></script>
306
- <script src="./js/main.js?v=20260318"></script>
307
-
308
- </body>
309
-
310
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/api/client.js DELETED
@@ -1,221 +0,0 @@
1
- // API Client Module - Backend communication
2
- APP.api.client = {};
3
-
4
- APP.api.client.hfDetectAsync = async function (formData) {
5
- const { state } = APP.core;
6
- if (!state.hf.baseUrl) return;
7
-
8
- const resp = await fetch(`${state.hf.baseUrl}/detect/async`, {
9
- method: "POST",
10
- body: formData
11
- });
12
-
13
- if (!resp.ok) {
14
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
15
- throw new Error(err.detail || "Async detection submission failed");
16
- }
17
-
18
- const data = await resp.json();
19
-
20
- // Store URLs from response
21
- if (data.status_url) {
22
- state.hf.statusUrl = data.status_url.startsWith("http")
23
- ? data.status_url
24
- : `${state.hf.baseUrl}${data.status_url}`;
25
- }
26
-
27
- if (data.video_url) {
28
- state.hf.videoUrl = data.video_url.startsWith("http")
29
- ? data.video_url
30
- : `${state.hf.baseUrl}${data.video_url}`;
31
- }
32
-
33
- if (data.depth_video_url) {
34
- state.hf.depthVideoUrl = data.depth_video_url.startsWith("http")
35
- ? data.depth_video_url
36
- : `${state.hf.baseUrl}${data.depth_video_url}`;
37
- }
38
-
39
- return data;
40
- };
41
-
42
- APP.api.client.checkJobStatus = async function (jobId) {
43
- const { state } = APP.core;
44
- if (!state.hf.baseUrl) return { status: "error" };
45
-
46
- const url = state.hf.statusUrl || `${state.hf.baseUrl}/detect/job/${jobId}`;
47
- const resp = await fetch(url, { cache: "no-store" });
48
-
49
- if (!resp.ok) {
50
- if (resp.status === 404) return { status: "not_found" };
51
- throw new Error(`Status check failed: ${resp.status}`);
52
- }
53
-
54
- return await resp.json();
55
- };
56
-
57
- APP.api.client.cancelBackendJob = async function (jobId, reason) {
58
- const { state } = APP.core;
59
- const { log } = APP.ui.logging;
60
-
61
- if (!state.hf.baseUrl || !jobId) return;
62
-
63
- // Don't attempt cancel on HF Space (it doesn't support it)
64
- if (state.hf.baseUrl.includes("hf.space")) {
65
- log(`Job cancel skipped for HF Space (${reason || "user request"})`, "w");
66
- return { status: "skipped", message: "Cancel disabled for HF Space" };
67
- }
68
-
69
- try {
70
- const resp = await fetch(`${state.hf.baseUrl}/detect/job/${jobId}`, {
71
- method: "DELETE"
72
- });
73
-
74
- if (resp.ok) {
75
- const result = await resp.json();
76
- log(`Job ${jobId.substring(0, 8)} cancelled`, "w");
77
- return result;
78
- }
79
- if (resp.status === 404) return { status: "not_found" };
80
- throw new Error("Cancel failed");
81
- } catch (err) {
82
- log(`Cancel error: ${err.message}`, "e");
83
- return { status: "error", message: err.message };
84
- }
85
- };
86
-
87
- APP.api.client.pollAsyncJob = async function () {
88
- const { state } = APP.core;
89
- const { log, setHfStatus } = APP.ui.logging;
90
- const { fetchProcessedVideo, fetchDepthVideo } = APP.core.video;
91
-
92
- const pollInterval = 3000; // 3 seconds
93
- const maxAttempts = 200; // 10 minutes max
94
- let attempts = 0;
95
- let fetchingVideo = false;
96
-
97
- return new Promise((resolve, reject) => {
98
- state.hf.asyncPollInterval = setInterval(async () => {
99
- attempts++;
100
-
101
- try {
102
- const resp = await fetch(state.hf.statusUrl, { cache: "no-store" });
103
-
104
- if (!resp.ok) {
105
- if (resp.status === 404) {
106
- clearInterval(state.hf.asyncPollInterval);
107
- reject(new Error("Job expired or not found"));
108
- return;
109
- }
110
- throw new Error(`Status check failed: ${resp.statusText}`);
111
- }
112
-
113
- const status = await resp.json();
114
- state.hf.asyncStatus = status.status;
115
- state.hf.asyncProgress = status;
116
-
117
- if (status.status === "completed") {
118
- if (fetchingVideo) return;
119
- fetchingVideo = true;
120
-
121
- const completedJobId = state.hf.asyncJobId;
122
- log(`Job ${completedJobId.substring(0, 8)}: completed`, "g");
123
- setHfStatus("job completed, fetching video...");
124
-
125
- try {
126
- await fetchProcessedVideo();
127
- await fetchDepthVideo();
128
-
129
- clearInterval(state.hf.asyncPollInterval);
130
- state.hf.completedJobId = state.hf.asyncJobId; // preserve for post-completion sync
131
- // Fetch timeline summary for heatmap
132
- APP.core.timeline.loadSummary();
133
- state.hf.asyncJobId = null;
134
- setHfStatus("ready");
135
- resolve();
136
- } catch (err) {
137
- if (err && err.code === "VIDEO_PENDING") {
138
- setHfStatus("job completed, finalizing video...");
139
- fetchingVideo = false;
140
- return;
141
- }
142
- clearInterval(state.hf.asyncPollInterval);
143
- state.hf.asyncJobId = null;
144
- reject(err);
145
- }
146
- } else if (status.status === "failed") {
147
- clearInterval(state.hf.asyncPollInterval);
148
- const errMsg = status.error || "Processing failed";
149
- log(`Job ${state.hf.asyncJobId.substring(0, 8)}: failed - ${errMsg}`, "e");
150
- state.hf.asyncJobId = null;
151
- setHfStatus(`error: ${errMsg}`);
152
- reject(new Error(errMsg));
153
- } else {
154
- // Still processing
155
- const progress = status.progress || 0;
156
- const progressPct = Math.round(progress * 100);
157
- const progressInfo = progress ? ` (${progressPct}%)` : "";
158
- setHfStatus(`job ${state.hf.asyncJobId.substring(0, 8)}: ${status.status}${progressInfo}`);
159
-
160
- // Update progress ring animation
161
- if (APP.ui.animations && APP.ui.animations.updateProgressRing) {
162
- const fraction = progress || Math.min(attempts / maxAttempts * 2, 0.9);
163
- const label = progress ? (progressPct + "%") : "Processing...";
164
- APP.ui.animations.updateProgressRing(fraction, label);
165
- }
166
- }
167
-
168
- if (attempts >= maxAttempts) {
169
- clearInterval(state.hf.asyncPollInterval);
170
- reject(new Error("Polling timeout (10 minutes)"));
171
- }
172
- } catch (err) {
173
- clearInterval(state.hf.asyncPollInterval);
174
- reject(err);
175
- }
176
- }, pollInterval);
177
- });
178
- };
179
-
180
- // External detection hook (can be replaced by user)
181
- APP.api.client.externalDetect = async function (input) {
182
- console.log("externalDetect called", input);
183
- return [];
184
- };
185
-
186
- // External features hook (can be replaced by user)
187
- APP.api.client.externalFeatures = async function (detections, frameInfo) {
188
- console.log("externalFeatures called for", detections.length, "objects");
189
- return {};
190
- };
191
-
192
- // External tracker hook (can be replaced by user)
193
- APP.api.client.externalTrack = async function (videoEl) {
194
- console.log("externalTrack called");
195
- return [];
196
- };
197
-
198
- APP.api.client.chatSend = async function (message, mission, activeObjects, history) {
199
- const { state } = APP.core;
200
- const base = state.hf.baseUrl;
201
- if (!base) throw new Error("Backend not configured");
202
-
203
- const resp = await fetch(`${base}/chat`, {
204
- method: "POST",
205
- headers: { "Content-Type": "application/json" },
206
- body: JSON.stringify({
207
- message,
208
- mission: mission || "",
209
- active_objects: activeObjects || [],
210
- history: history || []
211
- })
212
- });
213
-
214
- if (!resp.ok) {
215
- const err = await resp.json().catch(() => ({ detail: resp.statusText }));
216
- throw new Error(err.detail || "Chat request failed");
217
- }
218
-
219
- const data = await resp.json();
220
- return data.response;
221
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/api/inspection-api.js DELETED
@@ -1,228 +0,0 @@
1
- // Inspection API Client — fetches data for the deep-inspection panel
2
- APP.api.inspection = {};
3
-
4
- /**
5
- * Fetch a raw video frame as an Image element, optionally cropped to a track.
6
- * @param {string} jobId
7
- * @param {number} frameIdx
8
- * @param {string} [trackId] — if provided, crops to the track's bbox + padding
9
- * @param {number} [padding=0.20] — padding ratio around the bbox
10
- * @returns {Promise<HTMLImageElement>}
11
- */
12
- APP.api.inspection.fetchFrame = async function (jobId, frameIdx, trackId, padding) {
13
- const base = APP.core.state.hf.baseUrl;
14
- let url = `${base}/inspect/frame/${jobId}/${frameIdx}`;
15
- if (trackId) {
16
- const p = padding != null ? padding : 0.20;
17
- url += `?track_id=${encodeURIComponent(trackId)}&padding=${p}`;
18
- }
19
- const resp = await fetch(url);
20
- if (!resp.ok) throw new Error(`Frame fetch failed: ${resp.status}`);
21
-
22
- const blob = await resp.blob();
23
- const blobUrl = URL.createObjectURL(blob);
24
-
25
- return new Promise((resolve, reject) => {
26
- const img = new Image();
27
- img.onload = () => resolve(img);
28
- img.onerror = () => reject(new Error("Failed to decode frame image"));
29
- img.src = blobUrl;
30
- // Store blobUrl on the image so caller can revoke later
31
- img._blobUrl = blobUrl;
32
- });
33
- };
34
-
35
- /**
36
- * Fetch segmentation mask for a specific track on a specific frame.
37
- * @param {string} jobId
38
- * @param {number} frameIdx
39
- * @param {string} trackId
40
- * @returns {Promise<Object>} { track_id, bbox, color, mask (ImageData or RLE) }
41
- */
42
- APP.api.inspection.fetchMask = async function (jobId, frameIdx, trackId) {
43
- const base = APP.core.state.hf.baseUrl;
44
- const url = `${base}/inspect/mask/${jobId}/${frameIdx}/${encodeURIComponent(trackId)}`;
45
- const resp = await fetch(url);
46
-
47
- if (resp.status === 404) {
48
- // No pre-computed mask — generate one on-demand via SAM2
49
- return await APP.api.inspection.generateMask(jobId, frameIdx, trackId);
50
- }
51
- if (!resp.ok) throw new Error(`Mask fetch failed: ${resp.status}`);
52
- return await resp.json();
53
- };
54
-
55
- /**
56
- * Generate a mask on-demand via SAM2 (for detection mode jobs).
57
- * Result is cached server-side — subsequent fetchMask calls will find it.
58
- * @param {string} jobId
59
- * @param {number} frameIdx
60
- * @param {string} trackId
61
- * @returns {Promise<Object>} Same shape as fetchMask response
62
- */
63
- APP.api.inspection.generateMask = async function (jobId, frameIdx, trackId) {
64
- const base = APP.core.state.hf.baseUrl;
65
- const resp = await fetch(`${base}/inspect/generate-mask/${jobId}/${frameIdx}/${encodeURIComponent(trackId)}`, {
66
- method: "POST",
67
- headers: { "Content-Type": "application/json" },
68
- body: JSON.stringify({ sam2_size: "large" })
69
- });
70
- if (!resp.ok) throw new Error(`Mask generation failed: ${resp.status}`);
71
- return await resp.json();
72
- };
73
-
74
- /**
75
- * Fetch depth map for a specific frame, optionally cropped to a track.
76
- * @param {string} jobId
77
- * @param {number} frameIdx
78
- * @param {string} [trackId] — if provided, crops depth to the track's bbox
79
- * @returns {Promise<Object>} { width, height, min, max, data: Float32Array }
80
- */
81
- APP.api.inspection.fetchDepth = async function (jobId, frameIdx, trackId) {
82
- const base = APP.core.state.hf.baseUrl;
83
- // Try binary format first; fall back to JSON if CORS strips custom headers
84
- let url = `${base}/inspect/depth/${jobId}/${frameIdx}?format=raw`;
85
- if (trackId) {
86
- url += `&track_id=${encodeURIComponent(trackId)}`;
87
- }
88
- const resp = await fetch(url);
89
- if (!resp.ok) throw new Error(`Depth fetch failed: ${resp.status}`);
90
-
91
- const contentType = resp.headers.get("content-type") || "";
92
-
93
- if (contentType.includes("application/octet-stream")) {
94
- // Binary float32 format with metadata in headers
95
- const w = parseInt(resp.headers.get("X-Depth-Width"), 10);
96
- const h = parseInt(resp.headers.get("X-Depth-Height"), 10);
97
- const minD = parseFloat(resp.headers.get("X-Depth-Min"));
98
- const maxD = parseFloat(resp.headers.get("X-Depth-Max"));
99
- const buf = await resp.arrayBuffer();
100
- const data = new Float32Array(buf);
101
-
102
- // If CORS stripped headers, infer dimensions from data length
103
- if (isNaN(w) || isNaN(h)) {
104
- // Fall back to JSON format
105
- return await APP.api.inspection._fetchDepthJson(jobId, frameIdx, trackId);
106
- }
107
-
108
- return {
109
- width: w,
110
- height: h,
111
- min: isNaN(minD) ? 0 : minD,
112
- max: isNaN(maxD) ? 1 : maxD,
113
- data: data
114
- };
115
- } else {
116
- // JSON + base64 format
117
- const json = await resp.json();
118
- return APP.api.inspection._decodeDepthJson(json);
119
- }
120
- };
121
-
122
- /**
123
- * Fallback: fetch depth in JSON format if raw binary headers are unavailable.
124
- */
125
- APP.api.inspection._fetchDepthJson = async function (jobId, frameIdx, trackId) {
126
- const base = APP.core.state.hf.baseUrl;
127
- let url = `${base}/inspect/depth/${jobId}/${frameIdx}?format=json`;
128
- if (trackId) {
129
- url += `&track_id=${encodeURIComponent(trackId)}`;
130
- }
131
- const resp = await fetch(url);
132
- if (!resp.ok) throw new Error(`Depth (JSON) fetch failed: ${resp.status}`);
133
- const json = await resp.json();
134
- return APP.api.inspection._decodeDepthJson(json);
135
- };
136
-
137
- /**
138
- * Decode a JSON depth response to the standard { width, height, min, max, data } format.
139
- */
140
- APP.api.inspection._decodeDepthJson = function (json) {
141
- const raw = atob(json.data_b64);
142
- const buf = new ArrayBuffer(raw.length);
143
- const view = new Uint8Array(buf);
144
- for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
145
- return {
146
- width: json.width,
147
- height: json.height,
148
- min: json.min_depth,
149
- max: json.max_depth,
150
- data: new Float32Array(buf)
151
- };
152
- };
153
-
154
- /**
155
- * Fetch 3D mesh or point cloud data for a specific object.
156
- * @param {string} jobId
157
- * @param {number} frameIdx
158
- * @param {string} trackId
159
- * @param {boolean} [useGenerative=false] — if true, request Tripo3D generative model (API cost)
160
- * @returns {Promise<Object>} { renderMode, numVertices, numTriangles, positions, colors, indices?, bbox3d }
161
- */
162
- APP.api.inspection.fetchPointCloud = async function (jobId, frameIdx, trackId, useGenerative) {
163
- const base = APP.core.state.hf.baseUrl;
164
- const resp = await fetch(`${base}/inspect/pointcloud/${jobId}/${frameIdx}`, {
165
- method: "POST",
166
- headers: { "Content-Type": "application/json" },
167
- body: JSON.stringify({
168
- track_id: trackId,
169
- max_points: 50000,
170
- render_mode: "mesh",
171
- use_generative: !!useGenerative
172
- })
173
- });
174
- if (!resp.ok) throw new Error(`Point cloud fetch failed: ${resp.status}`);
175
-
176
- const contentType = resp.headers.get("content-type") || "";
177
-
178
- // Tripo3D returns GLB binary
179
- if (contentType.includes("model/gltf-binary")) {
180
- const buffer = await resp.arrayBuffer();
181
- return { type: "glb", buffer: buffer };
182
- }
183
-
184
- // Depth-based fallback returns JSON
185
- const json = await resp.json();
186
-
187
- function b64decode(b64, TypedArray) {
188
- const raw = atob(b64);
189
- const buf = new ArrayBuffer(raw.length);
190
- const view = new Uint8Array(buf);
191
- for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
192
- return new TypedArray(buf);
193
- }
194
-
195
- const result = {
196
- type: "legacy",
197
- renderMode: json.render_mode || "points",
198
- numVertices: json.num_vertices || json.num_points,
199
- numTriangles: json.num_triangles || 0,
200
- positions: b64decode(json.positions, Float32Array),
201
- colors: b64decode(json.colors, Uint8Array),
202
- bbox3d: json.bbox_3d
203
- };
204
-
205
- if (json.indices) {
206
- result.indices = b64decode(json.indices, Uint32Array);
207
- }
208
-
209
- return result;
210
- };
211
-
212
- /**
213
- * Fetch multi-LLM explainability tree for a tracked object.
214
- * @param {string} jobId
215
- * @param {string} trackId
216
- * @param {AbortSignal} [signal] — for aborting in-flight requests
217
- * @returns {Promise<Object>} Explanation tree with consensus data
218
- */
219
- APP.api.inspection.explainTrack = async function (jobId, trackId, signal) {
220
- const base = APP.core.state.hf.baseUrl;
221
- const url = `${base}/inspect/explain/${jobId}/${encodeURIComponent(trackId)}`;
222
- const resp = await fetch(url, { signal });
223
- if (!resp.ok) {
224
- const body = await resp.json().catch(() => ({}));
225
- throw new Error(body.detail || `Explain failed: ${resp.status}`);
226
- }
227
- return await resp.json();
228
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/config.js DELETED
@@ -1,15 +0,0 @@
1
- APP.core.CONFIG = {
2
- // API Endpoints will be loaded from window.API_CONFIG or defaults
3
- BACKEND_BASE: (window.API_CONFIG?.BACKEND_BASE || window.API_CONFIG?.BASE_URL || "").replace(/\/$/, "") || window.location.origin,
4
- HF_TOKEN: window.API_CONFIG?.HF_TOKEN || "",
5
- PROXY_URL: (window.API_CONFIG?.PROXY_URL || "").trim(),
6
-
7
- // Tracking Constants
8
- REASON_INTERVAL: 30,
9
- MAX_TRACKS: 50,
10
- TRACK_PRUNE_MS: 1500,
11
- TRACK_MATCH_THRESHOLD: 0.25,
12
-
13
- // Default Queries
14
- DEFAULT_QUERY_CLASSES: ["drone", "uav", "quadcopter", "fixed-wing", "missile", "person", "vehicle"]
15
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/demo.js DELETED
@@ -1,141 +0,0 @@
1
- APP.core.demo = {};
2
-
3
- APP.core.demo.data = null;
4
- APP.core.demo.active = false;
5
-
6
- APP.core.demo.load = async function () {
7
- // Demo data is now loaded per-video via loadForVideo()
8
- // This function is kept for compatibility but does nothing
9
- };
10
-
11
- APP.core.demo.getFrameData = function (currentTime) {
12
- if (!APP.core.demo.data) return null;
13
-
14
- // Use interpolation for keyframe format
15
- if (APP.core.demo.data.format === "keyframes") {
16
- return APP.core.demo.getFrameDataInterpolated(currentTime);
17
- }
18
-
19
- // Original logic for frame-by-frame data
20
- const fps = APP.core.demo.data.fps || 30;
21
- const frameIdx = Math.floor(currentTime * fps);
22
-
23
- // Get tracks for this frame
24
- // Handle both string and number keys if needed
25
- const tracks = APP.core.demo.data.frames[frameIdx] || APP.core.demo.data.frames[frameIdx.toString()];
26
-
27
- if (!tracks) return [];
28
- return tracks;
29
- };
30
-
31
- APP.core.demo.enable = function (force = true) {
32
- const { log } = APP.ui.logging;
33
- APP.core.demo.active = force;
34
- if (force) {
35
- log("DEMO MODE ACTIVATED", "g");
36
- const chipFeed = document.getElementById("chipFeed");
37
- if (chipFeed) chipFeed.textContent = "MODE:DEMO";
38
- }
39
- };
40
-
41
- // Keyframe interpolation for smooth radar movement
42
- APP.core.demo._keyframeIndices = null;
43
-
44
- APP.core.demo.getKeyframeIndices = function() {
45
- if (!APP.core.demo.data || APP.core.demo.data.format !== "keyframes") return null;
46
- if (APP.core.demo._keyframeIndices) return APP.core.demo._keyframeIndices;
47
-
48
- const indices = Object.keys(APP.core.demo.data.keyframes)
49
- .map(k => parseInt(k, 10))
50
- .sort((a, b) => a - b);
51
-
52
- APP.core.demo._keyframeIndices = indices;
53
- return indices;
54
- };
55
-
56
- APP.core.demo.interpolateTrack = function(trackA, trackB, t) {
57
- const { lerp } = APP.core.utils;
58
-
59
- return {
60
- id: trackA.id,
61
- label: trackA.label,
62
- bbox: {
63
- x: lerp(trackA.bbox.x, trackB.bbox.x, t),
64
- y: lerp(trackA.bbox.y, trackB.bbox.y, t),
65
- w: lerp(trackA.bbox.w, trackB.bbox.w, t),
66
- h: lerp(trackA.bbox.h, trackB.bbox.h, t)
67
- },
68
- angle_deg: trackA.angle_deg,
69
- speed_kph: lerp(trackA.speed_kph, trackB.speed_kph, t),
70
- depth_valid: false,
71
- depth_est_m: null,
72
- history: [],
73
- predicted_path: []
74
- };
75
- };
76
-
77
- APP.core.demo.getFrameDataInterpolated = function(currentTime) {
78
- const data = APP.core.demo.data;
79
- if (!data || data.format !== "keyframes") return null;
80
-
81
- const fps = data.fps || 24;
82
- const frameIdx = Math.floor(currentTime * fps);
83
- const keyframes = APP.core.demo.getKeyframeIndices();
84
-
85
- if (!keyframes || keyframes.length === 0) return [];
86
-
87
- // Find surrounding keyframes
88
- let beforeIdx = keyframes[0];
89
- let afterIdx = keyframes[keyframes.length - 1];
90
-
91
- for (let i = 0; i < keyframes.length; i++) {
92
- if (keyframes[i] <= frameIdx) beforeIdx = keyframes[i];
93
- if (keyframes[i] >= frameIdx) { afterIdx = keyframes[i]; break; }
94
- }
95
-
96
- // Edge cases
97
- if (frameIdx <= keyframes[0]) return data.keyframes[keyframes[0]] || [];
98
- if (frameIdx >= keyframes[keyframes.length - 1]) return data.keyframes[keyframes[keyframes.length - 1]] || [];
99
-
100
- // Interpolation factor
101
- const t = (beforeIdx === afterIdx) ? 0 : (frameIdx - beforeIdx) / (afterIdx - beforeIdx);
102
-
103
- const tracksBefore = data.keyframes[beforeIdx] || [];
104
- const tracksAfter = data.keyframes[afterIdx] || [];
105
-
106
- // Match by ID and interpolate
107
- const result = [];
108
- for (const trackA of tracksBefore) {
109
- const trackB = tracksAfter.find(tr => tr.id === trackA.id);
110
- if (trackB) {
111
- result.push(APP.core.demo.interpolateTrack(trackA, trackB, t));
112
- }
113
- }
114
-
115
- return result;
116
- };
117
-
118
- // Video-specific loading for demo tracks
119
- APP.core.demo.loadForVideo = async function(videoName) {
120
- const { log } = APP.ui.logging;
121
- if (videoName.toLowerCase().includes("enhance_video_movement")) {
122
- // Use global variable injected by helicopter_demo_data.js script tag (CORS-safe)
123
- if (window.HELICOPTER_DEMO_DATA) {
124
- APP.core.demo.data = window.HELICOPTER_DEMO_DATA;
125
- APP.core.demo._keyframeIndices = null; // Reset cache
126
- log("Helicopter demo tracks loaded (CORS-safe mode).", "g");
127
- return;
128
- }
129
- // Fallback to fetch (works when served from HTTP server)
130
- try {
131
- const resp = await fetch("data/helicopter_demo_tracks.json");
132
- if (resp.ok) {
133
- APP.core.demo.data = await resp.json();
134
- APP.core.demo._keyframeIndices = null; // Reset cache
135
- log("Helicopter demo tracks loaded.", "g");
136
- }
137
- } catch (err) {
138
- console.warn("Failed to load helicopter demo tracks:", err);
139
- }
140
- }
141
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/gptMapping.js DELETED
@@ -1,37 +0,0 @@
1
- /**
2
- * gptMapping.js — canonical GPT-raw → features field mapping.
3
- *
4
- * Replaces 4 identical inline mapping blocks across main.js, client.js,
5
- * and tracker.js (2 locations).
6
- */
7
- APP.core.gptMapping = {};
8
-
9
- /** Frozen assessment-status string constants. */
10
- APP.core.gptMapping.STATUS = Object.freeze({
11
- ASSESSED: "ASSESSED",
12
- UNASSESSED: "UNASSESSED",
13
- STALE: "STALE",
14
- PENDING_GPT: "PENDING_GPT",
15
- });
16
-
17
- /**
18
- * Build a features object from a gpt_raw payload.
19
- *
20
- * @param {Object|null|undefined} gptRaw - The gpt_raw dict from a detection.
21
- * @returns {Object} Features key-value map (empty object if gptRaw is falsy).
22
- */
23
- APP.core.gptMapping.buildFeatures = function (gptRaw) {
24
- if (!gptRaw) return {};
25
- const features = {};
26
- for (const [k, v] of Object.entries(gptRaw)) {
27
- if (k === "satisfies" || k === "reason") continue;
28
- features[k] = String(v);
29
- }
30
- if (gptRaw.satisfies !== undefined) {
31
- features["Satisfies"] = gptRaw.satisfies === true ? "Yes" : gptRaw.satisfies === false ? "No" : "—";
32
- }
33
- if (gptRaw.reason) {
34
- features["Reason"] = gptRaw.reason;
35
- }
36
- return features;
37
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/physics.js DELETED
@@ -1,18 +0,0 @@
1
- APP.core.physics = {};
2
-
3
- APP.core.physics.defaultAimpoint = function (label) {
4
- const l = (label || "object").toLowerCase();
5
- if (l.includes("airplane") || l.includes("drone") || l.includes("uav") || l.includes("kite") || l.includes("bird")) {
6
- return { relx: 0.62, rely: 0.55, label: "engine" };
7
- }
8
- if (l.includes("helicopter")) {
9
- return { relx: 0.50, rely: 0.45, label: "rotor_hub" };
10
- }
11
- if (l.includes("boat") || l.includes("ship")) {
12
- return { relx: 0.60, rely: 0.55, label: "bridge/engine" };
13
- }
14
- if (l.includes("truck") || l.includes("car")) {
15
- return { relx: 0.55, rely: 0.62, label: "engine_block" };
16
- }
17
- return { relx: 0.50, rely: 0.55, label: "center_mass" };
18
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/state.js DELETED
@@ -1,92 +0,0 @@
1
- APP.core.state = {
2
- videoUrl: null,
3
- videoFile: null,
4
- videoLoaded: false,
5
- useProcessedFeed: false,
6
- useDepthFeed: false, // Flag for depth view (Tab 2 video)
7
- hasReasoned: false,
8
- isReasoning: false, // Flag to prevent concurrent Reason executions
9
-
10
- hf: {
11
- // Will be properly initialized after CONFIG loads
12
- baseUrl: (window.API_CONFIG?.BACKEND_BASE || window.API_CONFIG?.BASE_URL || "").replace(/\/$/, "") || window.location.origin,
13
- detector: "auto",
14
- asyncJobId: null, // Current job ID from /detect/async
15
- completedJobId: null, // Preserved job ID for post-completion track sync
16
- asyncPollInterval: null, // Polling timer handle
17
- statusUrl: null, // Status polling URL
18
- videoUrl: null, // Final video URL
19
- asyncStatus: "idle", // "idle"|"processing"|"completed"|"failed"
20
- asyncProgress: null, // Progress data from status endpoint
21
- queries: [], // Mission objective used as query
22
- processedUrl: null,
23
- processedBlob: null,
24
- depthVideoUrl: null, // Depth video URL
25
- depthBlob: null, // Depth video blob
26
- summary: null,
27
- fps: null, // actual video FPS from backend summary
28
- totalFrames: null, // actual total frame count from backend
29
- busy: false,
30
- lastError: null,
31
- missionSpec: null,
32
- mode: null // "object_detection"|"segmentation"|"drone_detection"
33
- },
34
-
35
- detector: {
36
- mode: "coco",
37
- kind: "object",
38
- loaded: false,
39
- model: null,
40
- loading: false,
41
- cocoBlocked: false,
42
- hfTrackingWarned: false
43
- },
44
-
45
- tracker: {
46
- mode: "iou",
47
- tracks: [],
48
- nextId: 1,
49
- lastDetTime: 0,
50
- lastHFSync: 0,
51
- running: false,
52
- selectedTrackId: null,
53
- lastFrameTime: 0,
54
- frameCount: 0,
55
- _lastCardRenderFrame: 0, // Frame count at last card render
56
- heatmap: {}, // { frameIdx: trackCount } for timeline heatmap
57
- assessmentCache: {} // { track_id: { gpt_raw, satisfies, reason, ... } }
58
- },
59
-
60
- frame: {
61
- w: 1280,
62
- h: 720,
63
- bitmap: null
64
- },
65
-
66
- detections: [], // from Tab 1
67
- selectedIds: [],
68
-
69
- ui: {
70
- cursorMode: "on",
71
- agentCursor: { x: 0.65, y: 0.28, vx: 0, vy: 0, visible: false, target: null, mode: "idle", t0: 0 }
72
- },
73
-
74
- inspection: {
75
- visible: false,
76
- trackId: null,
77
- frameIdx: null,
78
- frameImageUrl: null,
79
- loading: false,
80
- error: null,
81
- expandedQuadrant: null, // null | "seg" | "edge" | "depth" | "3d"
82
-
83
- cache: {
84
- seg: null,
85
- edge: null,
86
- depth: null,
87
- pointcloud: null
88
- },
89
-
90
- explanationCache: {} // track_id -> explanation result (survives frame changes)
91
- }
92
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/timeline.js DELETED
@@ -1,167 +0,0 @@
1
- /**
2
- * timeline.js — Lazy heatmap timeline bar for Tab 2 (Track & Monitor).
3
- *
4
- * Renders a thin canvas bar showing:
5
- * - Detection density heatmap (colored by track count per frame)
6
- * - White playhead at current video position
7
- * - Click-to-seek interaction
8
- *
9
- * Heatmap data is populated lazily by tracker.syncWithBackend() writing
10
- * track counts into state.tracker.heatmap[frameIdx].
11
- */
12
- APP.core.timeline = {};
13
-
14
- (function () {
15
- const COLORS = {
16
- bg: "#111827", // unvisited
17
- empty: "#1e293b", // visited, 0 tracks
18
- low: "rgba(59, 130, 246, 0.35)", // 1-2 tracks
19
- mid: "rgba(59, 130, 246, 0.65)", // 3-4 tracks
20
- high: "rgba(96, 165, 250, 0.9)", // 5+ tracks
21
- playhead: "#ffffff",
22
- };
23
-
24
- function colorForCount(count) {
25
- if (count === undefined) return COLORS.bg;
26
- if (count === 0) return COLORS.empty;
27
- if (count <= 2) return COLORS.low;
28
- if (count <= 4) return COLORS.mid;
29
- return COLORS.high;
30
- }
31
-
32
- /** Render the timeline heatmap + playhead. Called every RAF frame. */
33
- APP.core.timeline.render = function () {
34
- const { state } = APP.core;
35
- const { $ } = APP.core.utils;
36
- const canvas = $("#timelineBar");
37
- const video = $("#videoEngage");
38
- if (!canvas || !video) return;
39
-
40
- // Cache duration once metadata loads (video.duration is NaN before that)
41
- if (video.duration && isFinite(video.duration)) {
42
- APP.core.timeline._cachedDuration = video.duration;
43
- }
44
- const duration = APP.core.timeline._cachedDuration;
45
- if (!duration) return;
46
-
47
- const wrap = canvas.parentElement;
48
- const rect = wrap.getBoundingClientRect();
49
- const dpr = window.devicePixelRatio || 1;
50
- const w = Math.floor(rect.width);
51
- const h = 14;
52
-
53
- if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
54
- canvas.width = w * dpr;
55
- canvas.height = h * dpr;
56
- canvas.style.width = w + "px";
57
- canvas.style.height = h + "px";
58
- }
59
-
60
- const ctx = canvas.getContext("2d");
61
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
62
-
63
- const fps = APP.core.state.hf.fps || 30;
64
- const totalFrames = Math.ceil(duration * fps);
65
- if (totalFrames <= 0) return;
66
-
67
- const heatmap = state.tracker.heatmap;
68
-
69
- // Draw heatmap segments
70
- // Group consecutive pixels mapping to same color to reduce draw calls
71
- const pxPerFrame = w / totalFrames;
72
-
73
- if (pxPerFrame >= 1) {
74
- // Enough room to draw per-frame
75
- for (let f = 0; f < totalFrames; f++) {
76
- const x = Math.floor(f * pxPerFrame);
77
- const nx = Math.floor((f + 1) * pxPerFrame);
78
- ctx.fillStyle = colorForCount(heatmap[f]);
79
- ctx.fillRect(x, 0, Math.max(nx - x, 1), h);
80
- }
81
- } else {
82
- // More frames than pixels — bucket frames per pixel
83
- for (let px = 0; px < w; px++) {
84
- const fStart = Math.floor((px / w) * totalFrames);
85
- const fEnd = Math.floor(((px + 1) / w) * totalFrames);
86
- let maxCount;
87
- for (let f = fStart; f < fEnd; f++) {
88
- const c = heatmap[f];
89
- if (c !== undefined && (maxCount === undefined || c > maxCount)) {
90
- maxCount = c;
91
- }
92
- }
93
- ctx.fillStyle = colorForCount(maxCount);
94
- ctx.fillRect(px, 0, 1, h);
95
- }
96
- }
97
-
98
- // Draw playhead
99
- const progress = video.currentTime / duration;
100
- const px = Math.round(progress * w);
101
- ctx.fillStyle = COLORS.playhead;
102
- ctx.fillRect(px - 1, 0, 2, h);
103
- };
104
-
105
- /** Initialize click-to-seek on the timeline canvas. */
106
- APP.core.timeline.init = function () {
107
- const { $ } = APP.core.utils;
108
- const canvas = $("#timelineBar");
109
- const video = $("#videoEngage");
110
- if (!canvas || !video) return;
111
-
112
- let dragging = false;
113
-
114
- function seek(e) {
115
- const rect = canvas.getBoundingClientRect();
116
- const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
117
- const ratio = x / rect.width;
118
- const dur = APP.core.timeline._cachedDuration || video.duration;
119
- if (dur && isFinite(dur)) {
120
- video.currentTime = ratio * dur;
121
- }
122
- }
123
-
124
- canvas.addEventListener("mousedown", function (e) {
125
- dragging = true;
126
- seek(e);
127
- });
128
- window.addEventListener("mousemove", function (e) {
129
- if (dragging) seek(e);
130
- });
131
- window.addEventListener("mouseup", function () {
132
- dragging = false;
133
- });
134
- };
135
-
136
- /** Fetch track summary from backend and populate heatmap + duration. */
137
- APP.core.timeline.loadSummary = async function () {
138
- const { state } = APP.core;
139
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
140
- if (!jobId || !state.hf.baseUrl) return;
141
-
142
- try {
143
- const resp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/summary`);
144
- if (!resp.ok) return;
145
-
146
- const data = await resp.json();
147
- const frames = data.frames || {};
148
-
149
- // Populate heatmap — keys come as strings from JSON, convert to int
150
- state.tracker.heatmap = {};
151
- for (const [idx, count] of Object.entries(frames)) {
152
- state.tracker.heatmap[parseInt(idx, 10)] = count;
153
- }
154
-
155
- // Cache duration from backend metadata (bypass video.duration dependency)
156
- if (data.total_frames > 0 && data.fps > 0) {
157
- APP.core.timeline._cachedDuration = data.total_frames / data.fps;
158
- state.hf.fps = data.fps;
159
- state.hf.totalFrames = data.total_frames;
160
- }
161
-
162
- console.log(`[timeline] Loaded summary: ${Object.keys(frames).length} frames, duration=${APP.core.timeline._cachedDuration}s`);
163
- } catch (e) {
164
- console.warn("[timeline] Failed to load summary", e);
165
- }
166
- };
167
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/tracker.js DELETED
@@ -1,288 +0,0 @@
1
- APP.core.tracker = {};
2
-
3
- APP.core.tracker.matchAndUpdateTracks = function (dets, dtSec) {
4
- const { state } = APP.core;
5
- const { CONFIG } = APP.core;
6
- const { normBBox, lerp, now, $ } = APP.core.utils;
7
- const { log } = APP.ui.logging;
8
-
9
- const videoEngage = $("#videoEngage");
10
-
11
- if (!videoEngage) return;
12
-
13
- // IOU helper
14
- function iou(a, b) {
15
- const ax2 = a.x + a.w, ay2 = a.y + a.h;
16
- const bx2 = b.x + b.w, by2 = b.y + b.h;
17
- const ix1 = Math.max(a.x, b.x), iy1 = Math.max(a.y, b.y);
18
- const ix2 = Math.min(ax2, bx2), iy2 = Math.min(ay2, by2);
19
- const iw = Math.max(0, ix2 - ix1), ih = Math.max(0, iy2 - iy1);
20
- const inter = iw * ih;
21
- const ua = a.w * a.h + b.w * b.h - inter;
22
- return ua <= 0 ? 0 : inter / ua;
23
- }
24
-
25
- // Convert detections to bbox in video coordinates
26
- const w = videoEngage.videoWidth || state.frame.w;
27
- const h = videoEngage.videoHeight || state.frame.h;
28
-
29
- const detObjs = dets.map(d => ({
30
- bbox: normBBox(d.bbox, w, h),
31
- label: d.class,
32
- score: d.score,
33
- depth_rel: Number.isFinite(d.depth_rel) ? d.depth_rel : null,
34
- depth_est_m: d.depth_est_m,
35
- depth_valid: d.depth_valid
36
- }));
37
-
38
- // mark all tracks as unmatched
39
- const tracks = state.tracker.tracks;
40
- const used = new Set();
41
-
42
- for (const tr of tracks) {
43
- let best = null;
44
- let bestI = 0.0;
45
- let bestIdx = -1;
46
- for (let i = 0; i < detObjs.length; i++) {
47
- if (used.has(i)) continue;
48
- const IoU = iou(tr.bbox, detObjs[i].bbox);
49
- if (IoU > bestI) {
50
- bestI = IoU;
51
- best = detObjs[i];
52
- bestIdx = i;
53
- }
54
- }
55
-
56
- // Strict matching threshold
57
- if (best && bestI >= CONFIG.TRACK_MATCH_THRESHOLD) {
58
- used.add(bestIdx);
59
-
60
- // Velocity with Exponential Moving Average (EMA) for smoothing
61
- const cx0 = tr.bbox.x + tr.bbox.w * 0.5;
62
- const cy0 = tr.bbox.y + tr.bbox.h * 0.5;
63
- const cx1 = best.bbox.x + best.bbox.w * 0.5;
64
- const cy1 = best.bbox.y + best.bbox.h * 0.5;
65
-
66
- const rawVx = (cx1 - cx0) / Math.max(1e-3, dtSec);
67
- const rawVy = (cy1 - cy0) / Math.max(1e-3, dtSec);
68
-
69
- // Alpha of 0.3 means 30% new value, 70% history
70
- tr.vx = tr.vx * 0.7 + rawVx * 0.3;
71
- tr.vy = tr.vy * 0.7 + rawVy * 0.3;
72
-
73
- // smooth bbox update
74
- tr.bbox.x = lerp(tr.bbox.x, best.bbox.x, 0.7);
75
- tr.bbox.y = lerp(tr.bbox.y, best.bbox.y, 0.7);
76
- tr.bbox.w = lerp(tr.bbox.w, best.bbox.w, 0.6);
77
- tr.bbox.h = lerp(tr.bbox.h, best.bbox.h, 0.6);
78
-
79
- // Logic: Only update label if the new detection is highly confident
80
- // AND the current track doesn't have a "premium" label (like 'drone').
81
- const protectedLabels = ["drone", "uav", "missile"];
82
- const isProtected = protectedLabels.some(l => (tr.label || "").toLowerCase().includes(l));
83
-
84
- if (!isProtected || (best.label && protectedLabels.some(l => best.label.toLowerCase().includes(l)))) {
85
- tr.label = best.label || tr.label;
86
- }
87
-
88
- tr.score = best.score || tr.score;
89
- if (Number.isFinite(best.depth_rel)) {
90
- tr.depth_rel = best.depth_rel;
91
- }
92
- if (best.depth_valid) {
93
- // EMA Smoothing
94
- const newD = best.depth_est_m;
95
- if (tr.depth_est_m == null) tr.depth_est_m = newD;
96
- else tr.depth_est_m = tr.depth_est_m * 0.7 + newD * 0.3;
97
- tr.depth_valid = true;
98
- }
99
- tr.lastSeen = now();
100
- } else {
101
- // Decay velocity
102
- tr.vx *= 0.9;
103
- tr.vy *= 0.9;
104
- }
105
- }
106
-
107
- // Limit total tracks
108
- if (tracks.length < CONFIG.MAX_TRACKS) {
109
- for (let i = 0; i < detObjs.length; i++) {
110
- if (used.has(i)) continue;
111
- // create new track only if big enough
112
- const a = detObjs[i].bbox.w * detObjs[i].bbox.h;
113
- if (a < (w * h) * 0.0005) continue;
114
-
115
- const newId = String(state.tracker.nextId++);
116
- tracks.push({
117
- id: newId,
118
- label: detObjs[i].label,
119
- bbox: { ...detObjs[i].bbox },
120
- score: detObjs[i].score,
121
- depth_rel: detObjs[i].depth_rel,
122
- depth_est_m: detObjs[i].depth_est_m,
123
- depth_valid: detObjs[i].depth_valid,
124
- satisfies: null,
125
- reason: null,
126
- lastSeen: now(),
127
- vx: 0, vy: 0,
128
- killed: false,
129
- state: "TRACK",
130
- });
131
- log(`New track created: ${newId} (${detObjs[i].label})`, "t");
132
- }
133
- }
134
-
135
- // prune old tracks
136
- const tNow = now();
137
- state.tracker.tracks = tracks.filter(tr => (tNow - tr.lastSeen) < CONFIG.TRACK_PRUNE_MS || tr.killed);
138
- };
139
-
140
- // Polling for backend tracks
141
- APP.core.tracker.syncWithBackend = async function (frameIdx) {
142
- const { state } = APP.core;
143
- const { $ } = APP.core.utils;
144
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
145
-
146
- if (!jobId || !state.hf.baseUrl) return;
147
-
148
- try {
149
- const resp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/${frameIdx}`);
150
- if (!resp.ok) return;
151
-
152
- const dets = await resp.json();
153
- if (!dets || !Array.isArray(dets)) return;
154
-
155
- // If backend returned empty for this frame, keep existing tracks
156
- if (dets.length === 0) return;
157
-
158
- // Transform backend format to frontend track format
159
- // Backend: { bbox: [x1, y1, x2, y2], label: "car", track_id: "T01", angle_deg: 90, ... }
160
- // Frontend: { id: "T01", bbox: {x,y,w,h}, label: "car", angle_deg: 90, ... }
161
-
162
- const videoEngage = $("#videoEngage");
163
- const w = videoEngage ? (videoEngage.videoWidth || state.frame.w) : state.frame.w;
164
- const h = videoEngage ? (videoEngage.videoHeight || state.frame.h) : state.frame.h;
165
-
166
- const newTracks = dets.map(d => {
167
- const x = d.bbox[0], y = d.bbox[1];
168
- const wBox = d.bbox[2] - d.bbox[0];
169
- const hBox = d.bbox[3] - d.bbox[1];
170
-
171
- // Normalize
172
- const nx = x / w;
173
- const ny = y / h;
174
- const nw = wBox / w;
175
- const nh = hBox / h;
176
-
177
- return {
178
- id: d.track_id || String(Math.floor(Math.random() * 1000)),
179
- label: d.label,
180
- bbox: { x: nx, y: ny, w: nw, h: nh },
181
- score: d.score,
182
- vx: 0,
183
- vy: 0,
184
- angle_deg: d.angle_deg,
185
- speed_kph: d.speed_kph,
186
- depth_est_m: d.depth_est_m,
187
- depth_rel: d.depth_rel,
188
- depth_valid: d.depth_valid,
189
- // Satisfaction reasoning
190
- satisfies: d.satisfies ?? null,
191
- reason: d.reason || null,
192
- // Mission relevance and assessment status
193
- mission_relevant: d.mission_relevant ?? null,
194
- relevance_reason: d.relevance_reason || null,
195
- assessment_status: d.assessment_status || APP.core.gptMapping.STATUS.UNASSESSED,
196
- assessment_frame_index: d.assessment_frame_index ?? null,
197
- // GPT raw data for feature table
198
- gpt_raw: d.gpt_raw || null,
199
- features: APP.core.gptMapping.buildFeatures(d.gpt_raw),
200
- // Keep UI state fields
201
- lastSeen: Date.now(),
202
- state: "TRACK"
203
- };
204
- });
205
-
206
- // ── Phase 1: Update assessment cache from tracks that arrived with GPT data ──
207
- const cache = state.tracker.assessmentCache;
208
- for (const track of newTracks) {
209
- if (track.gpt_raw || track.assessment_status === APP.core.gptMapping.STATUS.ASSESSED) {
210
- cache[track.id] = {
211
- gpt_raw: track.gpt_raw,
212
- satisfies: track.satisfies,
213
- reason: track.reason,
214
- mission_relevant: track.mission_relevant,
215
- relevance_reason: track.relevance_reason,
216
- assessment_status: track.assessment_status,
217
- assessment_frame_index: track.assessment_frame_index,
218
- features: track.features,
219
- };
220
- }
221
- }
222
-
223
- // ── Phase 2: Carry forward cached assessment for tracks missing GPT data ──
224
- for (const track of newTracks) {
225
- if (track.gpt_raw) continue;
226
- const cached = cache[track.id];
227
- if (!cached) continue;
228
- track.gpt_raw = cached.gpt_raw;
229
- track.satisfies = cached.satisfies ?? track.satisfies;
230
- track.reason = cached.reason || track.reason;
231
- track.mission_relevant = cached.mission_relevant ?? track.mission_relevant;
232
- track.relevance_reason = cached.relevance_reason || track.relevance_reason;
233
- track.assessment_status = cached.assessment_status || APP.core.gptMapping.STATUS.ASSESSED;
234
- track.assessment_frame_index = cached.assessment_frame_index ?? track.assessment_frame_index;
235
- track.features = cached.features && Object.keys(cached.features).length > 0
236
- ? cached.features
237
- : track.features;
238
- }
239
-
240
- // Check if mission verdicts exist (used for filtering cards, heatmap, logs)
241
- const hasVerdicts = newTracks.some(t => t.satisfies === true || t.satisfies === false);
242
-
243
- // Detect new objects before state update — only log MATCH items when verdicts exist
244
- const oldIds = new Set(state.tracker.tracks.map(t => t.id));
245
- const brandNew = newTracks.filter(t => !oldIds.has(t.id));
246
- const logWorthy = hasVerdicts
247
- ? brandNew.filter(t => t.satisfies === true)
248
- : brandNew;
249
- if (logWorthy.length > 0) {
250
- state.tracker._newObjectDetected = true;
251
- APP.ui.logging.log(`New objects: ${logWorthy.map(t => t.id).join(", ")}`, "t");
252
- }
253
-
254
- // Cache track count for timeline heatmap (only MATCH items when verdicts exist)
255
- const matchCount = hasVerdicts
256
- ? newTracks.filter(t => t.satisfies === true).length
257
- : newTracks.length;
258
- state.tracker.heatmap[frameIdx] = matchCount;
259
-
260
- // Update state
261
- state.tracker.tracks = newTracks;
262
- state.detections = newTracks; // Keep synced
263
-
264
- // Always trigger card re-render after backend sync (verdicts may have changed)
265
- state.tracker._newObjectDetected = true;
266
-
267
- } catch (e) {
268
- console.warn("Track sync failed", e);
269
- }
270
- };
271
-
272
- APP.core.tracker.predictTracks = function (dtSec) {
273
- const { state } = APP.core;
274
- const { $ } = APP.core.utils;
275
- const videoEngage = $("#videoEngage");
276
- if (!videoEngage) return;
277
- const w = videoEngage.videoWidth || state.frame.w;
278
- const h = videoEngage.videoHeight || state.frame.h;
279
-
280
- // Simple clamp util locally or imported
281
- const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
282
-
283
- state.tracker.tracks.forEach(tr => {
284
- if (tr.killed) return;
285
- tr.bbox.x = clamp(tr.bbox.x + (tr.vx || 0) * dtSec * 0.12, 0, w - 1);
286
- tr.bbox.y = clamp(tr.bbox.y + (tr.vy || 0) * dtSec * 0.12, 0, h - 1);
287
- });
288
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/utils.js DELETED
@@ -1,55 +0,0 @@
1
- APP.core.utils = {};
2
-
3
- APP.core.utils.$ = (sel, root = document) => root.querySelector(sel);
4
- APP.core.utils.$$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
5
-
6
- APP.core.utils.clamp = (x, a, b) => Math.min(b, Math.max(a, x));
7
- APP.core.utils.lerp = (a, b, t) => a + (b - a) * t;
8
- APP.core.utils.now = () => performance.now();
9
-
10
- APP.core.utils.escapeHtml = function (s) {
11
- return String(s).replace(/[&<>"']/g, m => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[m]));
12
- };
13
-
14
- APP.core.utils.canvasToBlob = function (canvas, quality = 0.88) {
15
- return new Promise((resolve, reject) => {
16
- if (!canvas.toBlob) { reject(new Error("Canvas.toBlob not supported")); return; }
17
- canvas.toBlob(blob => {
18
- if (!blob) { reject(new Error("Canvas toBlob failed")); return; }
19
- resolve(blob);
20
- }, "image/jpeg", quality);
21
- });
22
- };
23
-
24
- APP.core.utils.normBBox = function (bbox, w, h) {
25
- const [x, y, bw, bh] = bbox;
26
- return {
27
- x: APP.core.utils.clamp(x, 0, w - 1),
28
- y: APP.core.utils.clamp(y, 0, h - 1),
29
- w: APP.core.utils.clamp(bw, 1, w),
30
- h: APP.core.utils.clamp(bh, 1, h)
31
- };
32
- };
33
-
34
- APP.core.utils.loadedScripts = new Map();
35
-
36
- APP.core.utils.loadScriptOnce = function (key, src) {
37
- return new Promise((resolve, reject) => {
38
- if (APP.core.utils.loadedScripts.get(key) === "loaded") { resolve(); return; }
39
- if (APP.core.utils.loadedScripts.get(key) === "loading") {
40
- const iv = setInterval(() => {
41
- if (APP.core.utils.loadedScripts.get(key) === "loaded") { clearInterval(iv); resolve(); }
42
- if (APP.core.utils.loadedScripts.get(key) === "failed") { clearInterval(iv); reject(new Error("Script failed earlier")); }
43
- }, 50);
44
- return;
45
- }
46
-
47
- APP.core.utils.loadedScripts.set(key, "loading");
48
- const s = document.createElement("script");
49
- s.src = src;
50
- s.async = true;
51
- s.onload = () => { APP.core.utils.loadedScripts.set(key, "loaded"); resolve(); };
52
- s.onerror = () => { APP.core.utils.loadedScripts.set(key, "failed"); reject(new Error(`Failed to load ${src}`)); };
53
- document.head.appendChild(s);
54
- });
55
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/core/video.js DELETED
@@ -1,367 +0,0 @@
1
- // Video management: loading, unloading, first frame capture, depth handling
2
- APP.core.video = {};
3
-
4
- APP.core.video.frameToBitmap = async function (videoEl) {
5
- const w = videoEl.videoWidth || 1280;
6
- const h = videoEl.videoHeight || 720;
7
- const canvas = document.createElement("canvas");
8
- canvas.width = w;
9
- canvas.height = h;
10
- const ctx = canvas.getContext("2d");
11
- ctx.drawImage(videoEl, 0, 0, w, h);
12
- return canvas;
13
- };
14
-
15
- APP.core.video.seekTo = function (videoEl, time) {
16
- return new Promise((resolve) => {
17
- if (!videoEl) { resolve(); return; }
18
- videoEl.currentTime = Math.max(0, time);
19
- videoEl.onseeked = () => resolve();
20
- setTimeout(resolve, 600);
21
- });
22
- };
23
-
24
- APP.core.video.unloadVideo = async function (options = {}) {
25
- const { state } = APP.core;
26
- const { $, $$ } = APP.core.utils;
27
- const { log, setStatus, setHfStatus } = APP.ui.logging;
28
- const preserveInput = !!options.preserveInput;
29
-
30
- // Stop polling if running
31
- if (state.hf.asyncPollInterval) {
32
- clearInterval(state.hf.asyncPollInterval);
33
- state.hf.asyncPollInterval = null;
34
- }
35
-
36
- // Revoke blob URLs
37
- if (state.videoUrl && state.videoUrl.startsWith("blob:")) {
38
- URL.revokeObjectURL(state.videoUrl);
39
- }
40
- if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) {
41
- try { URL.revokeObjectURL(state.hf.processedUrl); } catch (_) { }
42
- }
43
- if (state.hf.depthVideoUrl && state.hf.depthVideoUrl.startsWith("blob:")) {
44
- try { URL.revokeObjectURL(state.hf.depthVideoUrl); } catch (_) { }
45
- }
46
- // Reset state
47
- state.videoUrl = null;
48
- state.videoFile = null;
49
- state.videoLoaded = false;
50
- state.useProcessedFeed = false;
51
- state.useDepthFeed = false;
52
-
53
- state.hf.processedUrl = null;
54
- state.hf.processedBlob = null;
55
- state.hf.depthVideoUrl = null;
56
- state.hf.depthBlob = null;
57
- state.hf.summary = null;
58
- state.hf.busy = false;
59
- state.hf.lastError = null;
60
- state.hf.asyncJobId = null;
61
- state.hf.completedJobId = null;
62
- state.hf.asyncStatus = "idle";
63
- state.hf.videoUrl = null;
64
- state.hf.fps = null;
65
- state.hf.totalFrames = null;
66
-
67
- setHfStatus("idle");
68
- state.hasReasoned = false;
69
- state.isReasoning = false;
70
-
71
- // Reset button states
72
- const btnReason = $("#btnReason");
73
- const btnCancelReason = $("#btnCancelReason");
74
- const btnEngage = $("#btnEngage");
75
-
76
- if (btnReason) {
77
- btnReason.disabled = false;
78
- btnReason.style.opacity = "1";
79
- btnReason.style.cursor = "pointer";
80
- }
81
- if (btnCancelReason) btnCancelReason.style.display = "none";
82
- if (btnEngage) btnEngage.disabled = true;
83
-
84
- state.detections = [];
85
- state.selectedIds = [];
86
-
87
- state.tracker.tracks = [];
88
- state.tracker.nextId = 1;
89
- state.tracker.running = false;
90
- state.tracker.selectedTrackId = null;
91
- state.tracker.assessmentCache = {};
92
- state.inspection.explanationCache = {};
93
-
94
- // Clear video elements
95
- const videoEngage = $("#videoEngage");
96
- const videoFile = $("#videoFile");
97
-
98
- if (videoEngage) {
99
- videoEngage.removeAttribute("src");
100
- videoEngage.load();
101
- }
102
- if (!preserveInput && videoFile) {
103
- videoFile.value = "";
104
- }
105
-
106
- // Update UI
107
- const videoMeta = $("#videoMeta");
108
- const engageEmpty = $("#engageEmpty");
109
- const engageNote = $("#engageNote");
110
-
111
- if (!preserveInput && videoMeta) videoMeta.textContent = "No file";
112
- if (engageEmpty) engageEmpty.style.display = "flex";
113
- if (engageNote) engageNote.textContent = "Awaiting video";
114
-
115
- // Re-render UI components
116
- if (APP.ui.cards.renderFrameTrackList) APP.ui.cards.renderFrameTrackList();
117
-
118
- setStatus("warn", "STANDBY · No video loaded");
119
- log("Video unloaded. Demo reset.", "w");
120
- };
121
-
122
- // Depth video handling
123
- APP.core.video.fetchDepthVideo = async function () {
124
- const { state } = APP.core;
125
- const { log } = APP.ui.logging;
126
-
127
- // Depth is optional - skip silently if no URL
128
- if (!state.hf.depthVideoUrl) {
129
- return;
130
- }
131
-
132
- try {
133
- const resp = await fetch(state.hf.depthVideoUrl, { cache: "no-store" });
134
-
135
- if (!resp.ok) {
136
- // 404 = depth not enabled/available - this is fine, not an error
137
- if (resp.status === 404) {
138
- state.hf.depthVideoUrl = null;
139
- return;
140
- }
141
- // 202 = still processing
142
- if (resp.status === 202) {
143
- return;
144
- }
145
- throw new Error(`Failed to fetch depth video: ${resp.statusText}`);
146
- }
147
-
148
- const nullOrigin = (window.location && window.location.origin) === "null" || (window.location && window.location.protocol === "file:");
149
- if (nullOrigin) {
150
- state.hf.depthBlob = null;
151
- state.hf.depthVideoUrl = `${state.hf.depthVideoUrl}?t=${Date.now()}`;
152
- log("Depth video ready (streaming URL)");
153
- return;
154
- }
155
-
156
- const blob = await resp.blob();
157
- state.hf.depthBlob = blob;
158
- const blobUrl = URL.createObjectURL(blob);
159
- state.hf.depthVideoUrl = blobUrl;
160
-
161
- log(`Depth video ready (${(blob.size / 1024 / 1024).toFixed(1)} MB)`, "g");
162
- APP.core.video.updateDepthChip();
163
- } catch (err) {
164
- // Don't log as error - depth is optional
165
- state.hf.depthVideoUrl = null;
166
- state.hf.depthBlob = null;
167
- }
168
- };
169
-
170
- APP.core.video.fetchProcessedVideo = async function () {
171
- const { state } = APP.core;
172
- const { log } = APP.ui.logging;
173
- const { $ } = APP.core.utils;
174
-
175
- const resp = await fetch(state.hf.videoUrl, { cache: "no-store" });
176
-
177
- if (!resp.ok) {
178
- if (resp.status === 202) {
179
- const err = new Error("Video still processing");
180
- err.code = "VIDEO_PENDING";
181
- throw err;
182
- }
183
- throw new Error(`Failed to fetch video: ${resp.statusText}`);
184
- }
185
-
186
- const nullOrigin = (window.location && window.location.origin) === "null" || (window.location && window.location.protocol === "file:");
187
- if (nullOrigin) {
188
- state.hf.processedBlob = null;
189
- state.hf.processedUrl = `${state.hf.videoUrl}?t=${Date.now()}`;
190
- const btnEngage = $("#btnEngage");
191
- if (btnEngage) btnEngage.disabled = false;
192
- log("Processed video ready (streaming URL)");
193
- return;
194
- }
195
-
196
- const blob = await resp.blob();
197
-
198
- if (state.hf.processedUrl && state.hf.processedUrl.startsWith("blob:")) {
199
- URL.revokeObjectURL(state.hf.processedUrl);
200
- }
201
-
202
- state.hf.processedBlob = blob;
203
- state.hf.processedUrl = URL.createObjectURL(blob);
204
-
205
- const btnEngage = $("#btnEngage");
206
- if (btnEngage) btnEngage.disabled = false;
207
- log(`Processed video ready (${(blob.size / 1024 / 1024).toFixed(1)} MB)`);
208
- };
209
-
210
- APP.core.video.updateDepthChip = function () {
211
- const { state } = APP.core;
212
- const { $ } = APP.core.utils;
213
-
214
- const chipDepth = $("#chipDepth");
215
- if (!chipDepth) return;
216
-
217
- if (state.hf.depthVideoUrl || state.hf.depthBlob) {
218
- chipDepth.style.cursor = "pointer";
219
- chipDepth.style.opacity = "1";
220
- } else {
221
- chipDepth.style.cursor = "not-allowed";
222
- chipDepth.style.opacity = "0.5";
223
- }
224
- };
225
-
226
- APP.core.video.toggleDepthView = function () {
227
- const { state } = APP.core;
228
- const { $, log } = APP.core.utils;
229
- const { log: uiLog } = APP.ui.logging;
230
-
231
- if (!state.hf.depthVideoUrl && !state.hf.depthBlob) {
232
- uiLog("Depth video not available yet. Run Reason and wait for processing.", "w");
233
- return;
234
- }
235
-
236
- state.useDepthFeed = !state.useDepthFeed;
237
-
238
- const videoEngage = $("#videoEngage");
239
- const chipDepth = $("#chipDepth");
240
-
241
- if (state.useDepthFeed) {
242
- if (chipDepth) chipDepth.textContent = "VIEW:DEPTH";
243
- if (videoEngage) {
244
- const currentTime = videoEngage.currentTime;
245
- const wasPlaying = !videoEngage.paused;
246
- videoEngage.src = state.hf.depthVideoUrl;
247
- videoEngage.load();
248
- videoEngage.currentTime = currentTime;
249
- if (wasPlaying) videoEngage.play();
250
- }
251
- } else {
252
- if (chipDepth) chipDepth.textContent = "VIEW:DEFAULT";
253
- if (videoEngage) {
254
- const currentTime = videoEngage.currentTime;
255
- const wasPlaying = !videoEngage.paused;
256
- const feedUrl = state.useProcessedFeed ? state.hf.processedUrl : state.videoUrl;
257
- videoEngage.src = feedUrl;
258
- videoEngage.load();
259
- videoEngage.currentTime = currentTime;
260
- if (wasPlaying) videoEngage.play();
261
- }
262
- }
263
- };
264
-
265
- APP.core.video.toggleProcessedFeed = function () {
266
- const { state } = APP.core;
267
- const { $ } = APP.core.utils;
268
- const { log } = APP.ui.logging;
269
-
270
- if (!state.hf.processedUrl) {
271
- log("Processed video not available yet", "w");
272
- return;
273
- }
274
-
275
- state.useProcessedFeed = !state.useProcessedFeed;
276
- state.useDepthFeed = false; // Reset depth view when switching feeds
277
-
278
- const videoEngage = $("#videoEngage");
279
- const chipFeed = $("#chipFeed");
280
- const chipDepth = $("#chipDepth");
281
-
282
- if (state.useProcessedFeed) {
283
- if (chipFeed) chipFeed.textContent = "FEED:HF";
284
- if (videoEngage) {
285
- const currentTime = videoEngage.currentTime;
286
- const wasPlaying = !videoEngage.paused;
287
- videoEngage.src = state.hf.processedUrl;
288
- videoEngage.load();
289
- videoEngage.currentTime = currentTime;
290
- if (wasPlaying) videoEngage.play();
291
- }
292
- } else {
293
- if (chipFeed) chipFeed.textContent = "FEED:RAW";
294
- if (videoEngage) {
295
- const currentTime = videoEngage.currentTime;
296
- const wasPlaying = !videoEngage.paused;
297
- videoEngage.src = state.videoUrl;
298
- videoEngage.load();
299
- videoEngage.currentTime = currentTime;
300
- if (wasPlaying) videoEngage.play();
301
- }
302
- }
303
-
304
- if (chipDepth) chipDepth.textContent = "VIEW:DEFAULT";
305
- };
306
-
307
- // ========= Streaming Mode for Tab 2 (Live Backend Processing) =========
308
-
309
- APP.core.video.setStreamingMode = function (url) {
310
- const { $ } = APP.core.utils;
311
- const videoEngage = $("#videoEngage");
312
- const engageEmpty = $("#engageEmpty");
313
-
314
- // Ensure stream image element exists
315
- let streamView = $("#streamView");
316
- if (!streamView) {
317
- streamView = document.createElement("img");
318
- streamView.id = "streamView";
319
- streamView.style.width = "100%";
320
- streamView.style.height = "100%";
321
- streamView.style.objectFit = "contain";
322
- streamView.style.position = "absolute";
323
- streamView.style.top = "0";
324
- streamView.style.left = "0";
325
- streamView.style.zIndex = "10"; // Above video
326
- streamView.style.backgroundColor = "#000";
327
-
328
- // Insert into the wrapper (parent of videoEngage)
329
- if (videoEngage && videoEngage.parentNode) {
330
- videoEngage.parentNode.appendChild(streamView);
331
- // Ensure container is relative for absolute positioning
332
- if (getComputedStyle(videoEngage.parentNode).position === "static") {
333
- videoEngage.parentNode.style.position = "relative";
334
- }
335
- }
336
- }
337
-
338
- if (streamView) {
339
- // Reset state
340
- streamView.style.display = "block";
341
- streamView.onerror = () => {
342
- // If stream fails (404 etc), silently revert
343
- streamView.style.display = "none";
344
- if (videoEngage) videoEngage.style.display = "block";
345
- if (engageEmpty && !videoEngage.src) engageEmpty.style.display = "flex";
346
- };
347
- streamView.src = url;
348
-
349
- if (videoEngage) videoEngage.style.display = "none";
350
-
351
- // Also hide empty state
352
- if (engageEmpty) engageEmpty.style.display = "none";
353
- }
354
- };
355
-
356
- APP.core.video.stopStreamingMode = function () {
357
- const { $ } = APP.core.utils;
358
- const videoEngage = $("#videoEngage");
359
-
360
- const streamView = $("#streamView");
361
- if (streamView) {
362
- streamView.src = ""; // Stop connection
363
- streamView.style.display = "none";
364
- }
365
- if (videoEngage) videoEngage.style.display = "block";
366
- };
367
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/init.js DELETED
@@ -1,6 +0,0 @@
1
- // Initialize Global Namespace
2
- window.APP = {
3
- core: {},
4
- ui: {},
5
- api: {}
6
- };
 
 
 
 
 
 
 
frontend/js/main.js DELETED
@@ -1,762 +0,0 @@
1
- // Main Entry Point - Wire up all event handlers and run the application
2
- document.addEventListener("DOMContentLoaded", () => {
3
- // Shortcuts
4
- const { state } = APP.core;
5
- const { $, $$ } = APP.core.utils;
6
- const { log, setStatus, setHfStatus } = APP.ui.logging;
7
- const { hfDetectAsync, checkJobStatus, cancelBackendJob, pollAsyncJob } = APP.api.client;
8
-
9
- // Core modules
10
- const { unloadVideo, toggleDepthView, toggleProcessedFeed, setStreamingMode, stopStreamingMode } = APP.core.video;
11
- const { load: loadDemo, getFrameData: getDemoFrameData, enable: enableDemo } = APP.core.demo;
12
-
13
- // UI Renderers
14
- const { renderFrameTrackList } = APP.ui.cards;
15
- const { render: renderOverlay, init: initOverlay } = APP.ui.overlays;
16
- const { tickAgentCursor, moveCursorToRect } = APP.ui.cursor;
17
- const { matchAndUpdateTracks, predictTracks } = APP.core.tracker;
18
-
19
- // DOM Elements
20
- const videoEngage = $("#videoEngage");
21
- const videoFile = $("#videoFile");
22
- const btnReason = $("#btnReason");
23
- const btnCancelReason = $("#btnCancelReason");
24
- const btnEject = $("#btnEject");
25
- const btnEngage = $("#btnEngage");
26
- const btnReset = $("#btnReset");
27
- const btnPause = $("#btnPause");
28
- const btnToggleSidebar = $("#btnToggleSidebar");
29
-
30
- const detectorSelect = $("#detectorSelect");
31
- const missionText = $("#missionText");
32
- const cursorMode = $("#cursorMode");
33
- const engageEmpty = $("#engageEmpty");
34
- const engageNote = $("#engageNote");
35
-
36
- const chipFeed = $("#chipFeed");
37
- const chipDepth = $("#chipDepth");
38
-
39
- // Animation module shortcuts
40
- const anim = APP.ui.animations;
41
-
42
- // Initialization
43
- function init() {
44
- log("System initializing...", "t");
45
-
46
- setupFileUpload();
47
- setupControls();
48
- setupKnobListeners();
49
- setupChipToggles();
50
-
51
- // Initial UI sync
52
- setHfStatus("idle");
53
-
54
- // Initialize SVG overlay
55
- initOverlay();
56
-
57
- // Initialize chat panel
58
- APP.ui.chat.init();
59
-
60
- // Initialize timeline seek interaction
61
- APP.core.timeline.init();
62
-
63
- // Initialize inspection panel
64
- APP.ui.inspection.init();
65
-
66
- // Initialize animation system
67
- if (anim && anim.init) anim.init();
68
-
69
- // Start main loop
70
- requestAnimationFrame(loop);
71
-
72
- // Load demo data (if available)
73
- loadDemo().then(() => {
74
- // hidden usage: enable if video filename matches "demo" or manually
75
- // APP.core.demo.enable(true);
76
- });
77
-
78
- log("System ready.", "g");
79
-
80
- // Update footer stat bar with initial model
81
- if (anim) {
82
- anim.updateModel(detectorSelect ? detectorSelect.options[detectorSelect.selectedIndex].text : "YOLO11");
83
- anim.updateStatus("Ready");
84
- }
85
- }
86
-
87
- function setupFileUpload() {
88
- if (!videoFile) return;
89
-
90
- videoFile.addEventListener("change", async (e) => {
91
- const file = e.target.files[0];
92
- if (!file) return;
93
-
94
- state.videoFile = file;
95
- state.videoUrl = URL.createObjectURL(file);
96
- state.videoLoaded = true;
97
-
98
- // Show meta
99
- const videoMeta = $("#videoMeta");
100
- if (videoMeta) videoMeta.textContent = file.name;
101
-
102
- // Load video into engage player
103
- if (videoEngage) {
104
- videoEngage.src = state.videoUrl;
105
- videoEngage.load();
106
- }
107
-
108
- // Hide empty states
109
- if (engageEmpty) engageEmpty.style.display = "none";
110
- if (engageNote) engageNote.textContent = "Ready";
111
-
112
- setStatus("warn", "Video loaded - ready to detect");
113
- log(`Video loaded: ${file.name}`, "g");
114
-
115
- // Toast + stat bar
116
- if (anim) {
117
- anim.toast("Video loaded: " + file.name, "success");
118
- anim.updateStatus("Ready");
119
- }
120
-
121
- // Load video-specific demo tracks (e.g., helicopter demo)
122
- if (APP.core.demo.loadForVideo) {
123
- await APP.core.demo.loadForVideo(file.name);
124
- }
125
-
126
- // Auto-enable demo mode if filename contains "demo" or helicopter video
127
- const shouldEnableDemo = file.name.toLowerCase().includes("demo") ||
128
- file.name.toLowerCase().includes("enhance_video_movement");
129
- if (shouldEnableDemo && APP.core.demo.data) {
130
- enableDemo(true);
131
- log("Auto-enabled DEMO mode for this video.", "g");
132
- }
133
- });
134
- }
135
-
136
- function setupControls() {
137
- // Reason button
138
- if (btnReason) {
139
- btnReason.addEventListener("click", runReason);
140
- }
141
-
142
- // Cancel Reason button
143
- if (btnCancelReason) {
144
- btnCancelReason.addEventListener("click", cancelReasoning);
145
- }
146
-
147
- // Eject button
148
- if (btnEject) {
149
- btnEject.addEventListener("click", async () => {
150
- await unloadVideo();
151
- });
152
- }
153
-
154
- // Engage button
155
- if (btnEngage) {
156
- btnEngage.addEventListener("click", runEngage);
157
- }
158
-
159
- // Pause / Resume toggle button
160
- if (btnPause) {
161
- btnPause.addEventListener("click", () => {
162
- if (!videoEngage) return;
163
- if (videoEngage.paused) {
164
- videoEngage.play().catch(err => {
165
- log(`Video resume failed: ${err.message}`, "e");
166
- });
167
- state.tracker.running = true;
168
- btnPause.textContent = "Resume";
169
- log("Tracking resumed.", "t");
170
- } else {
171
- videoEngage.pause();
172
- btnPause.textContent = "Pause";
173
- log("Video paused.", "t");
174
- }
175
- });
176
- }
177
-
178
- // Reset button
179
- if (btnReset) {
180
- btnReset.addEventListener("click", () => {
181
- if (videoEngage) {
182
- videoEngage.pause();
183
- videoEngage.currentTime = 0;
184
- }
185
- state.tracker.tracks = [];
186
- state.tracker.running = false;
187
- state.tracker.nextId = 1;
188
- state.tracker.heatmap = {};
189
- state.tracker.assessmentCache = {};
190
- state.inspection.explanationCache = {};
191
- state.hf.fps = null;
192
- state.hf.totalFrames = null;
193
- APP.core.timeline._cachedDuration = null;
194
- if (btnPause) btnPause.textContent = "Pause";
195
- renderFrameTrackList();
196
- log("Tracking reset.", "t");
197
- });
198
- }
199
-
200
- // Sidebar toggle (Tab 2)
201
- if (btnToggleSidebar) {
202
- btnToggleSidebar.addEventListener("click", () => {
203
- const engageGrid = $(".engage-grid");
204
- if (engageGrid) {
205
- engageGrid.classList.toggle("sidebar-collapsed");
206
- btnToggleSidebar.textContent = engageGrid.classList.contains("sidebar-collapsed")
207
- ? "Show Panel"
208
- : "Hide Panel";
209
- }
210
- });
211
- }
212
- }
213
-
214
-
215
-
216
- // Track selection event — multi-select toggle
217
- document.addEventListener("track-selected", (e) => {
218
- const clickedId = e.detail.id;
219
- const vid = $("#videoEngage");
220
-
221
- if (!clickedId) {
222
- // Click on empty area — deselect all
223
- state.selectedIds = [];
224
- state.tracker.selectedTrackId = null;
225
- APP.ui.chat.clear();
226
- APP.ui.inspection.close();
227
- renderFrameTrackList();
228
- renderOverlay();
229
- // Auto-resume playback
230
- if (vid && vid.paused && state.tracker.running) {
231
- vid.play().catch(() => {});
232
- if (btnPause) btnPause.textContent = "Pause";
233
- }
234
- return;
235
- }
236
-
237
- // Toggle: add or remove from selectedIds
238
- const idx = state.selectedIds.indexOf(clickedId);
239
- if (idx >= 0) {
240
- // Deselect this track
241
- state.selectedIds.splice(idx, 1);
242
- APP.ui.chat.removeTrackContext(clickedId);
243
- // Update selectedTrackId to last remaining, or null
244
- state.tracker.selectedTrackId = state.selectedIds.length > 0
245
- ? state.selectedIds[state.selectedIds.length - 1]
246
- : null;
247
- } else {
248
- // Select this track
249
- state.selectedIds.push(clickedId);
250
- state.tracker.selectedTrackId = clickedId;
251
- // Pause video to freeze on the selected object
252
- if (vid && !vid.paused) {
253
- vid.pause();
254
- if (btnPause) btnPause.textContent = "Resume";
255
- }
256
- }
257
-
258
- // Sync tracker.tracks from detections if empty
259
- if (state.tracker.tracks.length === 0 && state.detections.length > 0) {
260
- state.tracker.tracks = [...state.detections];
261
- }
262
-
263
- renderFrameTrackList();
264
- renderOverlay();
265
-
266
- // Open inspection for last-clicked track (even if deselecting, show last remaining)
267
- const inspectId = state.tracker.selectedTrackId;
268
- if (inspectId) {
269
- APP.ui.inspection.open(inspectId);
270
- } else {
271
- APP.ui.inspection.close();
272
- }
273
-
274
- // Inject track context into chat (additive)
275
- const selectedTrack = (state.detections || []).find(d => d.id === clickedId);
276
- if (selectedTrack && state.selectedIds.includes(clickedId)) {
277
- APP.ui.chat.injectTrackContext(selectedTrack);
278
- }
279
-
280
- // Scroll selected card into view
281
- const card = document.getElementById(`card-${clickedId}`);
282
- if (card) card.scrollIntoView({ behavior: "smooth", block: "nearest" });
283
- });
284
-
285
- // Cursor mode toggle
286
- if (cursorMode) {
287
- cursorMode.addEventListener("change", () => {
288
- state.ui.cursorMode = cursorMode.value;
289
- if (state.ui.cursorMode === "off" && APP.ui.cursor.setCursorVisible) {
290
- APP.ui.cursor.setCursorVisible(false);
291
- }
292
- });
293
- }
294
-
295
- function setupKnobListeners() {
296
- // Listen to all inputs and selects for knob updates
297
- const inputs = Array.from(document.querySelectorAll("input, select"));
298
- inputs.forEach(el => {
299
- el.addEventListener("input", () => {
300
- // Knob change handler (overlay rendering removed)
301
- });
302
- });
303
-
304
- // Update stat bar when detector changes
305
- if (detectorSelect) {
306
- detectorSelect.addEventListener("change", () => {
307
- if (anim) {
308
- anim.updateModel(detectorSelect.options[detectorSelect.selectedIndex].text);
309
- }
310
- });
311
- }
312
- }
313
-
314
- function setupChipToggles() {
315
- // Toggle processed/raw feed
316
- if (chipFeed) {
317
- chipFeed.style.cursor = "pointer";
318
- chipFeed.addEventListener("click", () => {
319
- if (!state.videoLoaded) return;
320
- toggleProcessedFeed();
321
- log(`Feed set to: ${state.useProcessedFeed ? "HF" : "RAW"}`, "t");
322
- });
323
- }
324
-
325
- // Toggle depth view (Tab 2)
326
- if (chipDepth) {
327
- chipDepth.style.cursor = "pointer";
328
- chipDepth.addEventListener("click", () => {
329
- if (!state.videoLoaded) return;
330
- toggleDepthView();
331
- log(`Engage view set to: ${state.useDepthFeed ? "DEPTH" : "DEFAULT"}`, "t");
332
- });
333
- }
334
-
335
- }
336
-
337
- async function runReason() {
338
- if (!state.videoLoaded) {
339
- log("No video loaded. Upload a video first.", "w");
340
- setStatus("warn", "Upload a video to begin");
341
- return;
342
- }
343
-
344
- if (state.isReasoning) {
345
- log("Detection already in progress. Please wait.", "w");
346
- return;
347
- }
348
-
349
- // Lock the Reason process
350
- state.isReasoning = true;
351
- if (btnReason) {
352
- btnReason.disabled = true;
353
- btnReason.style.opacity = "0.5";
354
- btnReason.style.cursor = "not-allowed";
355
- }
356
- if (btnCancelReason) btnCancelReason.style.display = "inline-block";
357
- if (btnEngage) btnEngage.disabled = true;
358
-
359
- // Clear previous detections
360
- state.detections = [];
361
- state.selectedIds = [];
362
- renderFrameTrackList();
363
-
364
- setStatus("warn", "Running detection pipeline");
365
-
366
- // Show progress ring + ambient processing state
367
- if (anim) {
368
- anim.showProgressRing("Initializing...");
369
- anim.setBodyState("processing");
370
- anim.setDotProcessing(true);
371
- anim.updateStatus("Processing");
372
- anim.toast("Detection pipeline started", "info");
373
- // Update model name in stat bar
374
- const modelText = detectorSelect ? detectorSelect.options[detectorSelect.selectedIndex].text : "YOLO11";
375
- anim.updateModel(modelText);
376
- }
377
-
378
- try {
379
- const selectedOption = detectorSelect ? detectorSelect.options[detectorSelect.selectedIndex] : null;
380
- const selectedValue = detectorSelect ? detectorSelect.value : "yolo11";
381
- const kind = selectedOption ? selectedOption.getAttribute("data-kind") : "object";
382
- const queries = missionText ? missionText.value.trim() : "";
383
- const enableDepth = false; // depth mode disabled
384
-
385
- // Determine mode and model parameter from data-kind attribute
386
- let mode, detectorParam, segmenterParam;
387
- if (kind === "segmentation") {
388
- mode = "segmentation";
389
- segmenterParam = selectedValue;
390
- detectorParam = "yolo11"; // default, unused for segmentation
391
- } else if (kind === "drone") {
392
- mode = "drone_detection";
393
- detectorParam = selectedValue;
394
- segmenterParam = "GSAM2-L";
395
- } else {
396
- mode = "object_detection";
397
- detectorParam = selectedValue;
398
- segmenterParam = "GSAM2-L";
399
- }
400
-
401
- const form = new FormData();
402
- form.append("video", state.videoFile);
403
- form.append("mode", mode);
404
- if (queries) form.append("queries", queries);
405
- form.append("detector", detectorParam);
406
- form.append("segmenter", segmenterParam);
407
- form.append("enable_depth", enableDepth ? "true" : "false");
408
-
409
- const missionVal = missionText ? missionText.value.trim() : "";
410
- if (missionVal) form.append("mission", missionVal);
411
-
412
- log(`Submitting job to ${state.hf.baseUrl}...`, "t");
413
- setHfStatus("submitting job...");
414
-
415
- const data = await hfDetectAsync(form);
416
-
417
- state.hf.asyncJobId = data.job_id;
418
- state.hf.mode = mode;
419
-
420
- // Store depth video URL if provided
421
- if (data.depth_video_url) {
422
- state.hf.depthVideoUrl = data.depth_video_url.startsWith("http")
423
- ? data.depth_video_url
424
- : `${state.hf.baseUrl}${data.depth_video_url}`;
425
- log("Depth video URL received", "t");
426
- }
427
-
428
- // Enable streaming mode if stream_url is provided (Tab 2 live view)
429
- const enableStream = $("#enableStreamToggle")?.checked;
430
-
431
- if (data.stream_url && enableStream) {
432
- const streamUrl = data.stream_url.startsWith("http")
433
- ? data.stream_url
434
- : `${state.hf.baseUrl}${data.stream_url}`;
435
- log("Activating live stream...", "t");
436
- setStreamingMode(streamUrl);
437
- log("Live view available in 'Track' tab.", "g");
438
- setStatus("warn", "Live processing... View in Track tab");
439
-
440
- // Poll tracks during live streaming (no video element to derive frameIdx from)
441
- const streamTrackPoll = setInterval(async () => {
442
- const jobId = state.hf.asyncJobId;
443
- if (!jobId || !state.hf.baseUrl) return;
444
- try {
445
- // Fetch summary to find latest frame with track data
446
- const sumResp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/summary`);
447
- if (!sumResp.ok) return;
448
- const summary = await sumResp.json();
449
- const frames = summary.frames || {};
450
- const frameIndices = Object.keys(frames).map(Number).filter(n => !isNaN(n));
451
- if (!frameIndices.length) return;
452
-
453
- const latestIdx = Math.max(...frameIndices);
454
- const trackResp = await fetch(`${state.hf.baseUrl}/detect/tracks/${jobId}/${latestIdx}`);
455
- if (!trackResp.ok) return;
456
- const dets = await trackResp.json();
457
- if (!Array.isArray(dets) || !dets.length) return;
458
-
459
- // Map to frontend format
460
- const newTracks = dets.map(d => {
461
- const bw = d.bbox[2] - d.bbox[0];
462
- const bh = d.bbox[3] - d.bbox[1];
463
- // Use stream image dimensions or fallback
464
- const streamView = $("#streamView");
465
- const fw = streamView ? (streamView.naturalWidth || 1280) : 1280;
466
- const fh = streamView ? (streamView.naturalHeight || 720) : 720;
467
- return {
468
- id: d.track_id || String(Math.floor(Math.random() * 1000)),
469
- label: d.label,
470
- bbox: { x: d.bbox[0] / fw, y: d.bbox[1] / fh, w: bw / fw, h: bh / fh },
471
- score: d.score,
472
- speed_kph: d.speed_kph,
473
- vx: 0,
474
- vy: 0,
475
- satisfies: d.satisfies ?? null,
476
- reason: d.reason || null,
477
- mission_relevant: d.mission_relevant ?? null,
478
- assessment_status: d.assessment_status || APP.core.gptMapping.STATUS.UNASSESSED,
479
- gpt_raw: d.gpt_raw || null,
480
- features: APP.core.gptMapping.buildFeatures(d.gpt_raw),
481
- lastSeen: Date.now(),
482
- state: "TRACK"
483
- };
484
- });
485
-
486
- state.detections = newTracks;
487
- state.tracker.tracks = newTracks;
488
- renderFrameTrackList();
489
- } catch (e) { /* ignore stream poll errors */ }
490
- }, 1000);
491
- state.hf._streamTrackPoll = streamTrackPoll;
492
- }
493
-
494
- // Start polling for completion
495
- pollAsyncJob().then(() => {
496
- log("Video processing complete.", "g");
497
- // Stop streaming track polling and streaming mode
498
- if (state.hf._streamTrackPoll) {
499
- clearInterval(state.hf._streamTrackPoll);
500
- state.hf._streamTrackPoll = null;
501
- }
502
- stopStreamingMode();
503
-
504
- state.hasReasoned = true;
505
- setStatus("good", "Detection complete - ready to track");
506
- log("Detection complete. Ready to Track.", "g");
507
-
508
- // Animation: complete state
509
- if (anim) {
510
- anim.updateProgressRing(1.0, "Complete");
511
- anim.setDotProcessing(false);
512
- anim.setBodyState("complete");
513
- anim.updateStatus("Complete");
514
- anim.toast("Detection complete - " + (state.detections || []).length + " objects found", "success", 5000);
515
- setTimeout(function() { anim.hideProgressRing(); }, 800);
516
- }
517
-
518
- // Seed tracks for Tab 2
519
- seedTracksFromTab1();
520
-
521
- // Re-enable engage button
522
- if (btnEngage) btnEngage.disabled = false;
523
- }).catch(err => {
524
- log(`Polling error: ${err.message}`, "e");
525
- if (state.hf._streamTrackPoll) {
526
- clearInterval(state.hf._streamTrackPoll);
527
- state.hf._streamTrackPoll = null;
528
- }
529
- stopStreamingMode();
530
-
531
- // Animation: error state
532
- if (anim) {
533
- anim.hideProgressRing();
534
- anim.setDotProcessing(false);
535
- anim.setBodyState("error");
536
- anim.updateStatus("Error");
537
- anim.toast("Detection failed: " + err.message, "error", 6000);
538
- }
539
- });
540
-
541
- // Initial status (processing in background)
542
- setStatus("warn", "Processing video...");
543
- log("Detection started...", "t");
544
-
545
-
546
-
547
- } catch (err) {
548
- setStatus("bad", "Detection failed");
549
- log(`Detection failed: ${err.message}`, "e");
550
- console.error(err);
551
-
552
- if (anim) {
553
- anim.hideProgressRing();
554
- anim.setDotProcessing(false);
555
- anim.setBodyState("error");
556
- anim.updateStatus("Error");
557
- anim.toast("Detection failed: " + err.message, "error", 6000);
558
- }
559
- } finally {
560
- state.isReasoning = false;
561
- if (btnReason) {
562
- btnReason.disabled = false;
563
- btnReason.style.opacity = "1";
564
- btnReason.style.cursor = "pointer";
565
- }
566
- if (btnCancelReason) btnCancelReason.style.display = "none";
567
- // Re-enable engage button in case of failure
568
- if (btnEngage) btnEngage.disabled = false;
569
- }
570
- }
571
-
572
- function seedTracksFromTab1() {
573
- // Preserve detections from streaming poll if available
574
- if (state.detections && state.detections.length > 0) {
575
- state.tracker.tracks = [...state.detections];
576
- } else {
577
- state.tracker.tracks = [];
578
- }
579
- state.tracker.nextId = 1;
580
- log("Tracks initialized.", "t");
581
- }
582
-
583
- function cancelReasoning() {
584
- // Stop HF polling if running
585
- if (state.hf.asyncPollInterval) {
586
- clearInterval(state.hf.asyncPollInterval);
587
- state.hf.asyncPollInterval = null;
588
- log("HF polling stopped.", "w");
589
- }
590
-
591
- // Stop streaming mode
592
- stopStreamingMode();
593
-
594
- // Cancel backend job if it exists
595
- const jobId = state.hf.asyncJobId;
596
- if (jobId) {
597
- cancelBackendJob(jobId, "cancel button");
598
- }
599
-
600
- // Reset state
601
- state.isReasoning = false;
602
- state.hf.busy = false;
603
- state.hf.asyncJobId = null;
604
- state.hf.completedJobId = null;
605
- state.hf.asyncStatus = "cancelled";
606
-
607
- // Re-enable Reason button
608
- if (btnReason) {
609
- btnReason.disabled = false;
610
- btnReason.style.opacity = "1";
611
- btnReason.style.cursor = "pointer";
612
- }
613
- if (btnCancelReason) btnCancelReason.style.display = "none";
614
-
615
- setStatus("warn", "Detection cancelled");
616
- setHfStatus("cancelled (stopped by user)");
617
- log("Detection cancelled by user.", "w");
618
-
619
- if (anim) {
620
- anim.hideProgressRing();
621
- anim.setDotProcessing(false);
622
- anim.setBodyState("idle");
623
- anim.updateStatus("Cancelled");
624
- anim.toast("Detection cancelled", "warning");
625
- }
626
- }
627
-
628
- function runEngage() {
629
- if (!state.hasReasoned) {
630
- log("Please run Detect first.", "w");
631
- return;
632
- }
633
-
634
- if (state.hf.asyncJobId) {
635
- log("Processing still in progress. Please wait.", "w");
636
- return;
637
- }
638
-
639
- // Set video source
640
- if (videoEngage) {
641
- videoEngage.src = state.hf.processedUrl || state.videoUrl;
642
- videoEngage.play().catch(err => {
643
- log(`Video playback failed: ${err.message}`, "e");
644
- });
645
- }
646
-
647
- state.tracker.running = true;
648
- state.tracker.lastFrameTime = APP.core.utils.now();
649
- if (btnPause) btnPause.textContent = "Pause";
650
-
651
- // Ensure tracks are seeded
652
- if (state.tracker.tracks.length === 0) {
653
- seedTracksFromTab1();
654
- }
655
-
656
- log("Tracking started.", "g");
657
-
658
- if (anim) {
659
- anim.setBodyState("complete");
660
- anim.updateStatus("Tracking");
661
- anim.toast("Real-time tracking active", "success");
662
- }
663
- }
664
-
665
- function loop() {
666
- const { now } = APP.core.utils;
667
- const t = now();
668
-
669
- // Guard against huge dt on first frame
670
- if (state.tracker.lastFrameTime === 0) state.tracker.lastFrameTime = t;
671
-
672
- const dt = Math.min((t - state.tracker.lastFrameTime) / 1000, 0.1);
673
- state.tracker.lastFrameTime = t;
674
-
675
- // ── Always keep track positions fresh (playing OR paused) ──
676
- // This ensures bboxes remain clickable regardless of playback state.
677
- if (state.tracker.running && videoEngage) {
678
- if (APP.core.demo.active && APP.core.demo.data) {
679
- // DEMO MODE: sync tracks to current video time (even when paused)
680
- const demoTracks = getDemoFrameData(videoEngage.currentTime);
681
- if (demoTracks) {
682
- const tracksClone = JSON.parse(JSON.stringify(demoTracks));
683
-
684
- state.tracker.tracks = tracksClone.map(d => ({
685
- ...d,
686
- lastSeen: t,
687
- state: "TRACK",
688
- depth_valid: false,
689
- depth_est_m: null,
690
- }));
691
-
692
- const w = videoEngage.videoWidth || state.frame.w || 1280;
693
- const h = videoEngage.videoHeight || state.frame.h || 720;
694
-
695
- state.tracker.tracks.forEach(tr => {
696
- if (tr.bbox.x > 1 || tr.bbox.w > 1) {
697
- tr.bbox.x /= w;
698
- tr.bbox.y /= h;
699
- tr.bbox.w /= w;
700
- tr.bbox.h /= h;
701
- }
702
- });
703
- }
704
- } else {
705
- // NORMAL MODE: predict positions every frame (only if tracks exist)
706
- if (state.tracker.tracks.length > 0) {
707
- predictTracks(dt);
708
- }
709
-
710
- // Backend sync every 333ms — always runs, even with zero tracks
711
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
712
- if (jobId && (t - state.tracker.lastHFSync > 333)) {
713
- const fps = state.hf.fps || 30;
714
- const maxFrame = state.hf.totalFrames ? state.hf.totalFrames - 1 : Infinity;
715
- const frameIdx = Math.min(Math.floor(videoEngage.currentTime * fps), maxFrame);
716
- if (isFinite(frameIdx) && frameIdx >= 0) {
717
- APP.core.tracker.syncWithBackend(frameIdx);
718
- }
719
- state.tracker.lastHFSync = t;
720
- }
721
-
722
- // Refresh inspection panel if frame changed
723
- if (APP.ui.inspection.refreshFrame) {
724
- APP.ui.inspection.refreshFrame();
725
- }
726
- }
727
- }
728
-
729
- // ── Card rendering ──
730
- if (state.tracker.running && videoEngage) {
731
- // Always render immediately when new objects arrive (even if paused)
732
- if (state.tracker._newObjectDetected) {
733
- renderFrameTrackList();
734
- state.tracker._lastCardRenderFrame = state.tracker.frameCount;
735
- state.tracker._newObjectDetected = false;
736
- }
737
-
738
- // Periodic re-render during active playback
739
- if (!videoEngage.paused) {
740
- state.tracker.frameCount++;
741
- const framesSinceRender = state.tracker.frameCount - state.tracker._lastCardRenderFrame;
742
- if (framesSinceRender >= 40) {
743
- renderFrameTrackList();
744
- state.tracker._lastCardRenderFrame = state.tracker.frameCount;
745
- }
746
- }
747
- }
748
-
749
- // Render UI
750
- renderOverlay();
751
- if (tickAgentCursor) tickAgentCursor();
752
- APP.core.timeline.render();
753
-
754
- requestAnimationFrame(loop);
755
- }
756
-
757
- // Expose state for debugging
758
- window.__LP_STATE__ = state;
759
-
760
- // Start
761
- init();
762
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/animations.js DELETED
@@ -1,146 +0,0 @@
1
- // Animations Module — Toast notifications, progress ring, stat counters, ambient state
2
- APP.ui.animations = {};
3
-
4
- // =========================================
5
- // Toast Notification System
6
- // =========================================
7
-
8
- APP.ui.animations._toastQueue = [];
9
- APP.ui.animations._toastMax = 4;
10
-
11
- /**
12
- * Show a toast notification.
13
- * @param {string} message - Text to display
14
- * @param {"info"|"success"|"warning"|"error"} type - Toast type
15
- * @param {number} duration - Auto-dismiss in ms (default 4000)
16
- */
17
- APP.ui.animations.toast = function (message, type, duration) {
18
- type = type || "info";
19
- duration = duration || 4000;
20
-
21
- var container = document.getElementById("toastContainer");
22
- if (!container) return;
23
-
24
- var toast = document.createElement("div");
25
- toast.className = "toast";
26
- toast.innerHTML =
27
- '<span class="toast-icon ' + type + '"></span>' +
28
- '<span class="toast-text">' + message + '</span>' +
29
- '<div class="toast-progress" style="animation-duration:' + duration + 'ms"></div>';
30
-
31
- container.appendChild(toast);
32
- APP.ui.animations._toastQueue.push(toast);
33
-
34
- // Enforce max visible
35
- while (APP.ui.animations._toastQueue.length > APP.ui.animations._toastMax) {
36
- var old = APP.ui.animations._toastQueue.shift();
37
- if (old && old.parentNode) old.parentNode.removeChild(old);
38
- }
39
-
40
- // Auto-dismiss
41
- setTimeout(function () {
42
- toast.classList.add("exiting");
43
- setTimeout(function () {
44
- if (toast.parentNode) toast.parentNode.removeChild(toast);
45
- var idx = APP.ui.animations._toastQueue.indexOf(toast);
46
- if (idx > -1) APP.ui.animations._toastQueue.splice(idx, 1);
47
- }, 280);
48
- }, duration);
49
- };
50
-
51
- // =========================================
52
- // Progress Ring (video overlay)
53
- // =========================================
54
-
55
- APP.ui.animations.showProgressRing = function (text) {
56
- var wrap = document.getElementById("progressRing");
57
- var ring = document.getElementById("progressRingFg");
58
- var label = document.getElementById("progressRingText");
59
- if (!wrap) return;
60
-
61
- wrap.classList.add("visible");
62
- if (label) label.textContent = text || "Processing...";
63
- if (ring) ring.style.strokeDashoffset = "264"; // 0%
64
- };
65
-
66
- APP.ui.animations.updateProgressRing = function (fraction, text) {
67
- var ring = document.getElementById("progressRingFg");
68
- var label = document.getElementById("progressRingText");
69
- if (!ring) return;
70
-
71
- // circumference = 2 * PI * 42 = ~264
72
- var offset = 264 - (264 * Math.min(Math.max(fraction, 0), 1));
73
- ring.style.strokeDashoffset = String(offset);
74
- if (label && text) label.textContent = text;
75
- };
76
-
77
- APP.ui.animations.hideProgressRing = function () {
78
- var wrap = document.getElementById("progressRing");
79
- if (wrap) wrap.classList.remove("visible");
80
- };
81
-
82
- // =========================================
83
- // Footer Stat Bar (animated counters)
84
- // =========================================
85
-
86
- APP.ui.animations._statValues = {};
87
-
88
- APP.ui.animations.updateStat = function (id, value) {
89
- var el = document.getElementById(id);
90
- if (!el) return;
91
-
92
- var prev = APP.ui.animations._statValues[id];
93
- if (prev === value) return;
94
-
95
- APP.ui.animations._statValues[id] = value;
96
- el.textContent = value;
97
-
98
- // Flash accent color on change
99
- el.classList.add("updated");
100
- setTimeout(function () { el.classList.remove("updated"); }, 600);
101
- };
102
-
103
- // Convenience: update track count with animated counter effect
104
- APP.ui.animations.animateTrackCount = function (count) {
105
- APP.ui.animations.updateStat("statTracks", String(count));
106
- };
107
-
108
- APP.ui.animations.updateModel = function (modelName) {
109
- APP.ui.animations.updateStat("statModel", modelName);
110
- };
111
-
112
- APP.ui.animations.updateStatus = function (statusText) {
113
- APP.ui.animations.updateStat("statStatus", statusText);
114
- };
115
-
116
- // =========================================
117
- // Ambient State Management
118
- // =========================================
119
-
120
- APP.ui.animations.setBodyState = function (state) {
121
- // Remove all state classes
122
- document.body.classList.remove("state-processing", "state-complete", "state-error", "state-idle");
123
- if (state) {
124
- document.body.classList.add("state-" + state);
125
- }
126
- };
127
-
128
- // Set processing dot to pulse
129
- APP.ui.animations.setDotProcessing = function (active) {
130
- var dot = document.getElementById("sys-dot");
131
- if (!dot) return;
132
- if (active) {
133
- dot.classList.add("processing");
134
- } else {
135
- dot.classList.remove("processing");
136
- }
137
- };
138
-
139
- // =========================================
140
- // Init (called from main.js)
141
- // =========================================
142
-
143
- APP.ui.animations.init = function () {
144
- // Set initial state
145
- APP.ui.animations.setBodyState("idle");
146
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/cards.js DELETED
@@ -1,122 +0,0 @@
1
- APP.ui.cards = {};
2
-
3
- APP.ui.cards.renderFrameTrackList = function () {
4
- const { state } = APP.core;
5
- const { $ } = APP.core.utils;
6
- const frameTrackList = $("#frameTrackList");
7
- const trackList = $("#trackList"); // Tab 2 (Engage) uses the same card logic
8
- const trackCount = $("#trackCount");
9
-
10
- if (!frameTrackList && !trackList) return;
11
- if (frameTrackList) frameTrackList.innerHTML = "";
12
- if (trackList) trackList.innerHTML = "";
13
-
14
- // Filter: show only MATCH items when mission assessment is active
15
- const allDets = state.detections || [];
16
- const hasVerdicts = allDets.some(d => d.satisfies === true || d.satisfies === false);
17
- const dets = hasVerdicts ? allDets.filter(d => d.satisfies === true) : allDets;
18
-
19
- if (trackCount) trackCount.textContent = dets.length;
20
-
21
- // Update footer stat
22
- if (APP.ui.animations && APP.ui.animations.animateTrackCount) {
23
- APP.ui.animations.animateTrackCount(dets.length);
24
- }
25
-
26
- if (dets.length === 0) {
27
- const emptyMsg = '<div style="color:var(--text3); text-align:center; margin-top:20px; font-size:12px;">No objects detected yet.</div>';
28
- if (frameTrackList) frameTrackList.innerHTML = emptyMsg;
29
- if (trackList) trackList.innerHTML = emptyMsg;
30
- return;
31
- }
32
-
33
- // Sort: mission-relevant first, then assessed (high→low conf), then unassessed, then stale
34
- const sorted = [...dets].sort((a, b) => {
35
- // 1. Mission-relevant objects always on top
36
- const relA = a.mission_relevant === true ? 0 : 1;
37
- const relB = b.mission_relevant === true ? 0 : 1;
38
- if (relA !== relB) return relA - relB;
39
- // 2. Assessed before unassessed before stale
40
- const S = APP.core.gptMapping.STATUS;
41
- const statusOrder = { [S.ASSESSED]: 0, [S.UNASSESSED]: 1, [S.STALE]: 2 };
42
- const statusA = statusOrder[a.assessment_status] ?? 1;
43
- const statusB = statusOrder[b.assessment_status] ?? 1;
44
- if (statusA !== statusB) return statusA - statusB;
45
- // 3. Higher confidence first
46
- return (b.score || 0) - (a.score || 0);
47
- });
48
-
49
- sorted.forEach((det, i) => {
50
- const id = det.id || String(i + 1);
51
- const isActive = state.selectedIds.includes(id);
52
- const isInspected = state.tracker.selectedTrackId === id;
53
-
54
- const card = document.createElement("div");
55
- card.className = "track-card" + (isActive ? " active" : "") + (isInspected ? " inspected" : "");
56
- card.id = `card-${id}`;
57
- // Staggered entrance animation
58
- card.style.animationDelay = (i * 40) + "ms";
59
- if (det.mission_relevant === false) {
60
- card.style.opacity = "0.4";
61
- }
62
-
63
- card.onclick = () => {
64
- const ev = new CustomEvent("track-selected", { detail: { id } });
65
- document.dispatchEvent(ev);
66
- };
67
-
68
- // Single unified status badge
69
- const S = APP.core.gptMapping.STATUS;
70
- const assessStatus = det.assessment_status || S.UNASSESSED;
71
- let statusBadge = "";
72
- if (det.satisfies === true) {
73
- statusBadge = '<span class="badgemini" style="background:rgba(52,211,153,.15); color:#34d399; border:1px solid rgba(52,211,153,.25)">Match</span>';
74
- } else if (det.satisfies === false) {
75
- statusBadge = '<span class="badgemini" style="background:rgba(248,113,113,.12); color:#f87171; border:1px solid rgba(248,113,113,.2)">No Match</span>';
76
- } else if (det.mission_relevant === true) {
77
- statusBadge = '<span class="badgemini" style="background:rgba(59,130,246,.12); color:#60a5fa; border:1px solid rgba(59,130,246,.2)">Relevant</span>';
78
- } else if (assessStatus === S.STALE) {
79
- statusBadge = '<span class="badgemini" style="background:rgba(251,191,36,.1); color:#fbbf24; border:1px solid rgba(251,191,36,.18)">Stale</span>';
80
- } else if (det.mission_relevant === false) {
81
- statusBadge = '<span class="badgemini" style="background:rgba(255,255,255,.04); color:rgba(255,255,255,.4); border:1px solid rgba(255,255,255,.08)">N/R</span>';
82
- } else {
83
- statusBadge = `<span class="badgemini" style="background:rgba(255,255,255,.05); color:rgba(255,255,255,.6); border:1px solid rgba(255,255,255,.08)">${(det.score * 100).toFixed(0)}%</span>`;
84
- }
85
-
86
- // Satisfaction reason (collapsed summary)
87
- const desc = det.reason
88
- ? `<div class="track-card-body"><span class="gpt-text">${det.reason}</span></div>`
89
- : "";
90
-
91
- // Inline features (only shown when active/expanded)
92
- let featuresHtml = "";
93
- if (isInspected && det.features && Object.keys(det.features).length > 0) {
94
- const entries = Object.entries(det.features).slice(0, 12);
95
- const rows = entries.map(([k, v]) =>
96
- `<div class="feat-row"><span class="feat-key">${k}</span><span class="feat-val">${String(v)}</span></div>`
97
- ).join("");
98
- featuresHtml = `<div class="track-card-features">${rows}</div>`;
99
- }
100
-
101
- card.innerHTML = `
102
- <div class="track-card-header">
103
- <span>${det.label}</span>
104
- ${statusBadge}
105
- </div>
106
- ${desc}
107
- ${featuresHtml}
108
- `;
109
- if (frameTrackList) frameTrackList.appendChild(card);
110
- if (trackList) trackList.appendChild(card.cloneNode(true));
111
- });
112
-
113
- // Wire up click handlers on cloned Tab 2 cards
114
- if (trackList) {
115
- trackList.querySelectorAll(".track-card").forEach(card => {
116
- const id = card.id.replace("card-", "");
117
- card.onclick = () => {
118
- document.dispatchEvent(new CustomEvent("track-selected", { detail: { id } }));
119
- };
120
- });
121
- }
122
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/chat.js DELETED
@@ -1,320 +0,0 @@
1
- // Chat UI Module - Mission Analyst Chatbot (Multi-Object Context)
2
- APP.ui.chat = {};
3
-
4
- APP.ui.chat._history = [];
5
- APP.ui.chat._trackContexts = {}; // { trackId: trackObject }
6
- APP.ui.chat._msgCounter = 0;
7
-
8
- APP.ui.chat.init = function () {
9
- const { $ } = APP.core.utils;
10
- const input = $("#chatInput");
11
- const btn = $("#chatSend");
12
-
13
- if (input) {
14
- input.addEventListener("keydown", (e) => {
15
- if (e.key === "Enter" && !e.shiftKey) {
16
- e.preventDefault();
17
- APP.ui.chat.send();
18
- }
19
- });
20
- }
21
- if (btn) {
22
- btn.addEventListener("click", () => APP.ui.chat.send());
23
- }
24
-
25
- const clearBtn = $("#chatClear");
26
- if (clearBtn) {
27
- clearBtn.addEventListener("click", () => APP.ui.chat.clear());
28
- }
29
- };
30
-
31
- APP.ui.chat.send = async function () {
32
- const { $ } = APP.core.utils;
33
- const input = $("#chatInput");
34
- if (!input) return;
35
-
36
- const message = input.value.trim();
37
- if (!message) return;
38
-
39
- input.value = "";
40
- input.disabled = true;
41
-
42
- const missionText = $("#missionText");
43
- const mission = missionText ? missionText.value.trim() : "";
44
-
45
- // Add user message to UI and history
46
- APP.ui.chat._addMessage("user", message);
47
- APP.ui.chat._history.push({ role: "user", content: message });
48
-
49
- // Show loading indicator
50
- const loadingId = APP.ui.chat._addMessage("assistant", "", true);
51
-
52
- try {
53
- const activeObjects = Object.values(APP.ui.chat._trackContexts);
54
- const response = await APP.api.client.chatSend(
55
- message,
56
- mission,
57
- activeObjects,
58
- APP.ui.chat._history
59
- );
60
-
61
- // Remove loading, add real response
62
- APP.ui.chat._removeMessage(loadingId);
63
- APP.ui.chat._addMessage("assistant", response);
64
- APP.ui.chat._history.push({ role: "assistant", content: response });
65
- } catch (err) {
66
- APP.ui.chat._removeMessage(loadingId);
67
- APP.ui.chat._addMessage("system", "Error: " + err.message);
68
- }
69
-
70
- input.disabled = false;
71
- input.focus();
72
- };
73
-
74
- /**
75
- * Add a track to the active context (additive — does not clear).
76
- */
77
- APP.ui.chat.injectTrackContext = function (track) {
78
- if (!track) return;
79
-
80
- // Skip if already in context
81
- if (APP.ui.chat._trackContexts[track.id]) return;
82
-
83
- APP.ui.chat._trackContexts[track.id] = track;
84
-
85
- // Build visible context summary
86
- const parts = [`${track.id} \u00b7 ${track.label}`];
87
- if (track.assessment_status) parts.push(track.assessment_status);
88
- if (track.satisfies === true) parts.push("satisfies=YES");
89
- else if (track.satisfies === false) parts.push("satisfies=NO");
90
- if (track.mission_relevant === true) parts.push("RELEVANT");
91
- else if (track.mission_relevant === false) parts.push("NOT RELEVANT");
92
- if (track.score != null) parts.push(`conf=${(track.score * 100).toFixed(0)}%`);
93
- if (track.reason) parts.push(track.reason);
94
-
95
- APP.ui.chat._addMessage("marker", "Context loaded for " + parts.join(" \u00b7 "));
96
-
97
- // Update header and suggestions
98
- APP.ui.chat._renderHeader();
99
- APP.ui.chat._renderSuggestions();
100
- APP.ui.chat._updatePlaceholder();
101
- };
102
-
103
- /**
104
- * Remove a track from active context.
105
- */
106
- APP.ui.chat.removeTrackContext = function (trackId) {
107
- if (!APP.ui.chat._trackContexts[trackId]) return;
108
-
109
- const track = APP.ui.chat._trackContexts[trackId];
110
- delete APP.ui.chat._trackContexts[trackId];
111
-
112
- APP.ui.chat._addMessage("marker", "\u2500\u2500 " + track.id + " " + track.label + " removed from context \u2500\u2500");
113
-
114
- APP.ui.chat._renderHeader();
115
- APP.ui.chat._renderSuggestions();
116
- APP.ui.chat._updatePlaceholder();
117
- };
118
-
119
- /**
120
- * Full clear — reset everything.
121
- */
122
- APP.ui.chat.clear = function () {
123
- const { $ } = APP.core.utils;
124
- const container = $("#chatMessages");
125
- if (container) container.innerHTML = "";
126
- APP.ui.chat._history = [];
127
- APP.ui.chat._trackContexts = {};
128
- APP.ui.chat._renderHeader();
129
- APP.ui.chat._renderSuggestions();
130
- APP.ui.chat._updatePlaceholder();
131
- };
132
-
133
- /**
134
- * Render the persistent context header with chips.
135
- */
136
- APP.ui.chat._renderHeader = function () {
137
- const { $ } = APP.core.utils;
138
- const header = $("#chatContextHeader");
139
- const chips = $("#chatContextChips");
140
- if (!header || !chips) return;
141
-
142
- const contexts = Object.values(APP.ui.chat._trackContexts);
143
-
144
- if (contexts.length === 0) {
145
- header.style.display = "none";
146
- return;
147
- }
148
-
149
- header.style.display = "flex";
150
- const esc = APP.core.utils.escapeHtml;
151
- chips.innerHTML = contexts.map(t => {
152
- let statusClass = "unassessed";
153
- let statusDot = "\u25CF";
154
- if (t.satisfies === true) statusClass = "match";
155
- else if (t.satisfies === false) statusClass = "no-match";
156
-
157
- return `<span class="chat-context-chip">
158
- ${esc(t.id)} <span class="chip-label">${esc(t.label)}</span>
159
- <span class="chip-status ${statusClass}">${statusDot}</span>
160
- </span>`;
161
- }).join("");
162
- };
163
-
164
- /**
165
- * Generate and render suggestion chips (only when 2+ objects).
166
- */
167
- APP.ui.chat._renderSuggestions = function () {
168
- const { $ } = APP.core.utils;
169
- const container = $("#chatSuggestions");
170
- if (!container) return;
171
-
172
- const contexts = Object.values(APP.ui.chat._trackContexts);
173
-
174
- if (contexts.length < 2) {
175
- container.style.display = "none";
176
- return;
177
- }
178
-
179
- const suggestions = APP.ui.chat._generateSuggestions(contexts);
180
-
181
- if (suggestions.length === 0) {
182
- container.style.display = "none";
183
- return;
184
- }
185
-
186
- container.style.display = "flex";
187
- container.innerHTML = suggestions.map(s =>
188
- `<span class="chat-suggestion-chip">${s}</span>`
189
- ).join("");
190
-
191
- // Wire click handlers
192
- container.querySelectorAll(".chat-suggestion-chip").forEach(chip => {
193
- chip.addEventListener("click", () => {
194
- const input = $("#chatInput");
195
- if (input) {
196
- input.value = chip.textContent;
197
- APP.ui.chat.send();
198
- }
199
- });
200
- });
201
- };
202
-
203
- /**
204
- * Rule-based suggestion generation from active object set.
205
- * Returns top 3-4 suggestions.
206
- */
207
- APP.ui.chat._generateSuggestions = function (objects) {
208
- const suggestions = [];
209
-
210
- // Rule 1: Mixed satisfaction
211
- const matches = objects.filter(o => o.satisfies === true);
212
- const noMatches = objects.filter(o => o.satisfies === false);
213
- if (matches.length > 0 && noMatches.length > 0) {
214
- suggestions.push(`Why does ${noMatches[0].id} fail mission?`);
215
- }
216
-
217
- // Rule 2: Different classes
218
- const labels = [...new Set(objects.map(o => o.label))];
219
- if (labels.length > 1 && objects.length <= 4) {
220
- const a = objects[0], b = objects.find(o => o.label !== a.label);
221
- if (b) suggestions.push(`Compare ${a.id} (${a.label}) and ${b.id} (${b.label})`);
222
- }
223
-
224
- // Rule 3: Speed delta
225
- const withSpeed = objects.filter(o => o.speed_kph != null && o.speed_kph > 0);
226
- if (withSpeed.length >= 2) {
227
- const speeds = withSpeed.map(o => o.speed_kph);
228
- const delta = Math.max(...speeds) - Math.min(...speeds);
229
- if (delta > 15) suggestions.push("Which is moving faster?");
230
- }
231
-
232
- // Rule 4: Depth delta
233
- const withDepth = objects.filter(o => o.depth_est_m != null && o.depth_est_m > 0);
234
- if (withDepth.length >= 2) {
235
- const depths = withDepth.map(o => o.depth_est_m);
236
- const delta = Math.max(...depths) - Math.min(...depths);
237
- if (delta > 50) suggestions.push("Compare distances");
238
- }
239
-
240
- // Rule 5: Both match mission
241
- if (matches.length >= 2) {
242
- suggestions.push("Which is higher priority?");
243
- }
244
-
245
- // Rule 6: Low confidence
246
- const lowConf = objects.find(o => o.score != null && o.score < 0.7);
247
- if (lowConf) {
248
- suggestions.push(`How confident is ${lowConf.id} classification?`);
249
- }
250
-
251
- // Rule 7: 3+ objects — aggregate
252
- if (objects.length >= 3) {
253
- suggestions.push("Rank by threat level");
254
- }
255
-
256
- // Rule 8: Stale assessment
257
- const S = APP.core.gptMapping ? APP.core.gptMapping.STATUS : {};
258
- const stale = objects.find(o => o.assessment_status === (S.STALE || "STALE"));
259
- if (stale) {
260
- suggestions.push(`Is ${stale.id} assessment still valid?`);
261
- }
262
-
263
- // Rule 9: Mixed motion (stationary vs moving)
264
- const moving = objects.filter(o => o.speed_kph != null && o.speed_kph > 2);
265
- const stationary = objects.filter(o => o.speed_kph == null || o.speed_kph <= 2);
266
- if (moving.length > 0 && stationary.length > 0) {
267
- suggestions.push("Compare movement patterns");
268
- }
269
-
270
- // Return top 4, deduplicated
271
- return suggestions.slice(0, 4);
272
- };
273
-
274
- /**
275
- * Update input placeholder based on context state.
276
- */
277
- APP.ui.chat._updatePlaceholder = function () {
278
- const { $ } = APP.core.utils;
279
- const input = $("#chatInput");
280
- if (!input) return;
281
-
282
- const count = Object.keys(APP.ui.chat._trackContexts).length;
283
- if (count === 0) {
284
- input.placeholder = "Select a track to start...";
285
- } else if (count === 1) {
286
- input.placeholder = "Ask about loaded object...";
287
- } else {
288
- input.placeholder = `Ask about ${count} loaded objects...`;
289
- }
290
- };
291
-
292
- APP.ui.chat._addMessage = function (role, content, loading) {
293
- const { $ } = APP.core.utils;
294
- const container = $("#chatMessages");
295
- if (!container) return null;
296
-
297
- const id = "chat-msg-" + (++APP.ui.chat._msgCounter);
298
- const div = document.createElement("div");
299
- div.className = "chat-message chat-" + role + (loading ? " loading" : "");
300
- div.id = id;
301
-
302
- const esc = APP.core.utils.escapeHtml;
303
- if (role === "marker") {
304
- div.innerHTML = `<span class="chat-content">${esc(content)}</span>`;
305
- } else {
306
- const safe = (role === "user") ? esc(content) : content;
307
- const labels = { user: "You", assistant: "AI", system: "SYS" };
308
- div.innerHTML = `<span class="chat-icon">${labels[role] || ""}</span><span class="chat-content">${safe}</span>`;
309
- }
310
-
311
- container.appendChild(div);
312
- container.scrollTop = container.scrollHeight;
313
- return id;
314
- };
315
-
316
- APP.ui.chat._removeMessage = function (id) {
317
- if (!id) return;
318
- const el = document.getElementById(id);
319
- if (el) el.remove();
320
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/cursor.js DELETED
@@ -1,90 +0,0 @@
1
- // Agent Cursor Animation Module
2
- APP.ui.cursor = {};
3
-
4
- APP.ui.cursor.ensureAgentCursorOverlay = function () {
5
- const { $ } = APP.core.utils;
6
- if ($("#agentCursor")) return;
7
-
8
- const el = document.createElement("div");
9
- el.id = "agentCursor";
10
- el.style.cssText = `
11
- position: fixed;
12
- width: 12px;
13
- height: 12px;
14
- border-radius: 50%;
15
- background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(96, 165, 250, 0.9));
16
- box-shadow: 0 0 16px rgba(59, 130, 246, 0.5), 0 0 32px rgba(96, 165, 250, 0.3);
17
- pointer-events: none;
18
- z-index: 10000;
19
- opacity: 0;
20
- display: none;
21
- transition: opacity 0.3s ease;
22
- `;
23
- document.body.appendChild(el);
24
- };
25
-
26
- APP.ui.cursor.setCursorVisible = function (visible) {
27
- const { $ } = APP.core.utils;
28
- const { state } = APP.core;
29
-
30
- APP.ui.cursor.ensureAgentCursorOverlay();
31
- const el = $("#agentCursor");
32
-
33
- if (!el) return;
34
-
35
- state.ui.agentCursor.visible = visible;
36
- el.style.opacity = visible ? "1" : "0";
37
- el.style.display = visible ? "block" : "none";
38
- };
39
-
40
- APP.ui.cursor.moveCursorToRect = function (rect) {
41
- const { state } = APP.core;
42
- const { $, now } = APP.core.utils;
43
-
44
- if (state.ui.cursorMode === "off") return;
45
-
46
- APP.ui.cursor.ensureAgentCursorOverlay();
47
- const el = $("#agentCursor");
48
-
49
- if (!el) return;
50
-
51
- const c = state.ui.agentCursor;
52
- c.visible = true;
53
- c.target = rect;
54
- c.t0 = now();
55
- el.style.opacity = "1";
56
- el.style.display = "block";
57
- };
58
-
59
- APP.ui.cursor.tickAgentCursor = function () {
60
- const { state } = APP.core;
61
- const { $, clamp, now } = APP.core.utils;
62
- const el = $("#agentCursor");
63
-
64
- if (!el || state.ui.cursorMode !== "on" || !state.ui.agentCursor.visible) return;
65
-
66
- const c = state.ui.agentCursor;
67
- if (!c.target) return;
68
-
69
- const tx = c.target.left + c.target.width * 0.72;
70
- const ty = c.target.top + c.target.height * 0.50;
71
-
72
- // Smooth spring physics
73
- const dx = tx - (c.x * window.innerWidth);
74
- const dy = ty - (c.y * window.innerHeight);
75
- c.vx = (c.vx + dx * 0.0018) * 0.85;
76
- c.vy = (c.vy + dy * 0.0018) * 0.85;
77
-
78
- const px = (c.x * window.innerWidth) + c.vx * 18;
79
- const py = (c.y * window.innerHeight) + c.vy * 18;
80
- c.x = clamp(px / window.innerWidth, 0.02, 0.98);
81
- c.y = clamp(py / window.innerHeight, 0.02, 0.98);
82
-
83
- el.style.transform = `translate(${c.x * window.innerWidth}px, ${c.y * window.innerHeight}px)`;
84
-
85
- // Hide after settling
86
- const settle = Math.hypot(dx, dy);
87
- if (settle < 6 && (now() - c.t0) > 650) {
88
- el.style.opacity = "0.75";
89
- }
90
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/explainability.js DELETED
@@ -1,367 +0,0 @@
1
- /**
2
- * explainability.js — Interactive hierarchical interpretability graph.
3
- *
4
- * Renders a D3.js tree showing why an object was classified,
5
- * with multi-LLM consensus indicators and hover tooltips.
6
- */
7
- APP.ui.explainability = {};
8
-
9
- /** @type {AbortController|null} */
10
- APP.ui.explainability._abortCtrl = null;
11
-
12
- /** Category color fallback */
13
- APP.ui.explainability._COLORS = {
14
- Structure: "#3b82f6", Function: "#06b6d4", Material: "#f59e0b",
15
- Color: "#ef4444", Size: "#10b981", Type: "#8b5cf6",
16
- Motion: "#ec4899", Context: "#64748b", Shape: "#f97316", Markings: "#a855f7",
17
- };
18
-
19
- /**
20
- * Fetch + render the explainability graph for a track.
21
- * Aborts any previous in-flight request.
22
- */
23
- APP.ui.explainability.load = async function (jobId, trackId) {
24
- const { state } = APP.core;
25
- const container = document.getElementById("interpretabilityGraph");
26
- if (!container) return;
27
-
28
- // Check cache first
29
- const cached = state.inspection.explanationCache[trackId];
30
- if (cached) {
31
- APP.ui.explainability.render(cached, container);
32
- return;
33
- }
34
-
35
- // Abort previous request
36
- if (APP.ui.explainability._abortCtrl) {
37
- APP.ui.explainability._abortCtrl.abort();
38
- }
39
- APP.ui.explainability._abortCtrl = new AbortController();
40
-
41
- // Show loading
42
- container.innerHTML =
43
- '<div class="explain-loading">' +
44
- '<div class="explain-spinner"></div>' +
45
- '<span>Analyzing with GPT-4o, Claude, and Gemini...</span>' +
46
- '</div>';
47
-
48
- try {
49
- const data = await APP.api.inspection.explainTrack(
50
- jobId, trackId, APP.ui.explainability._abortCtrl.signal
51
- );
52
- state.inspection.explanationCache[trackId] = data;
53
- APP.ui.explainability.render(data, container);
54
- } catch (err) {
55
- if (err.name === "AbortError") return; // Aborted — new track selected
56
- container.innerHTML =
57
- '<div class="explain-error">' + _escHtml(err.message) + '</div>';
58
- }
59
- };
60
-
61
- /**
62
- * Render the hierarchical tree inside a container element.
63
- */
64
- APP.ui.explainability.render = function (data, container) {
65
- container.innerHTML = "";
66
-
67
- if (!data || !data.categories || data.categories.length === 0) {
68
- container.innerHTML = '<div class="explain-error">No explanation data available</div>';
69
- return;
70
- }
71
-
72
- // Build D3 hierarchy data
73
- const root = {
74
- name: (data.object || "OBJECT").toUpperCase(),
75
- confidence: data.confidence,
76
- satisfies: data.satisfies,
77
- summary: data.reasoning_summary,
78
- children: data.categories.map(cat => ({
79
- name: cat.name,
80
- color: cat.color || APP.ui.explainability._COLORS[cat.name] || "#64748b",
81
- isCategory: true,
82
- children: (cat.features || []).map(f => ({
83
- name: f.name,
84
- value: f.value,
85
- reasoning: f.reasoning,
86
- validators: f.validators || {},
87
- consensus: f.consensus || 0,
88
- color: cat.color || APP.ui.explainability._COLORS[cat.name] || "#64748b",
89
- isFeature: true,
90
- })),
91
- })),
92
- };
93
-
94
- // Dimensions
95
- const margin = { top: 30, right: 20, bottom: 60, left: 20 };
96
- const width = container.clientWidth || 500;
97
- const totalLeaves = root.children.reduce((s, c) => s + (c.children ? c.children.length : 1), 0);
98
- const height = Math.max(280, totalLeaves * 35 + 120);
99
-
100
- // Create SVG
101
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
102
- svg.setAttribute("width", width);
103
- svg.setAttribute("height", height);
104
- svg.setAttribute("class", "explain-svg");
105
- container.appendChild(svg);
106
-
107
- // Glow filter
108
- const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
109
- defs.innerHTML =
110
- '<filter id="explainGlow"><feGaussianBlur stdDeviation="3" result="blur"/>' +
111
- '<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>';
112
- svg.appendChild(defs);
113
-
114
- // D3 tree layout
115
- const hierarchy = d3.hierarchy(root);
116
- const treeLayout = d3.tree().size([
117
- width - margin.left - margin.right,
118
- height - margin.top - margin.bottom,
119
- ]);
120
- treeLayout(hierarchy);
121
-
122
- const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
123
- g.setAttribute("transform", `translate(${margin.left},${margin.top})`);
124
- svg.appendChild(g);
125
-
126
- // Draw edges (curved paths)
127
- hierarchy.links().forEach(link => {
128
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
129
- const sx = link.source.x, sy = link.source.y;
130
- const tx = link.target.x, ty = link.target.y;
131
- const my = (sy + ty) / 2;
132
- path.setAttribute("d", `M${sx},${sy} C${sx},${my} ${tx},${my} ${tx},${ty}`);
133
- path.setAttribute("fill", "none");
134
- path.setAttribute("stroke", link.target.data.color || "#3b82f6");
135
- path.setAttribute("stroke-width", link.source.depth === 0 ? "2.5" : "1.5");
136
- path.setAttribute("opacity", "0.5");
137
- path.setAttribute("filter", "url(#explainGlow)");
138
- g.appendChild(path);
139
- });
140
-
141
- // Draw nodes
142
- hierarchy.descendants().forEach(node => {
143
- const d = node.data;
144
- const ng = document.createElementNS("http://www.w3.org/2000/svg", "g");
145
- ng.setAttribute("transform", `translate(${node.x},${node.y})`);
146
-
147
- if (node.depth === 0) {
148
- // Root node
149
- _drawRootNode(ng, d);
150
- } else if (d.isCategory) {
151
- // Category node
152
- _drawCategoryNode(ng, d);
153
- } else if (d.isFeature) {
154
- // Feature leaf node
155
- _drawFeatureNode(ng, d);
156
- }
157
-
158
- // Tooltip on hover
159
- ng.addEventListener("mouseenter", (e) => _showTooltip(e, d, container));
160
- ng.addEventListener("mouseleave", () => _hideTooltip(container));
161
-
162
- g.appendChild(ng);
163
- });
164
-
165
- // Consensus bar
166
- if (data.consensus_bar) {
167
- _drawConsensusBar(svg, data.consensus_bar, width, height, margin);
168
- }
169
- };
170
-
171
- /**
172
- * Clear the graph and remove tooltip.
173
- */
174
- APP.ui.explainability.clear = function () {
175
- const container = document.getElementById("interpretabilityGraph");
176
- if (container) container.innerHTML = "";
177
- if (APP.ui.explainability._abortCtrl) {
178
- APP.ui.explainability._abortCtrl.abort();
179
- APP.ui.explainability._abortCtrl = null;
180
- }
181
- };
182
-
183
-
184
- // ── Private helpers ──────────────────────────────────────────────
185
-
186
- function _drawRootNode(g, d) {
187
- const w = 120, h = 32, rx = 8;
188
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
189
- rect.setAttribute("x", -w / 2); rect.setAttribute("y", -h / 2);
190
- rect.setAttribute("width", w); rect.setAttribute("height", h);
191
- rect.setAttribute("rx", rx); rect.setAttribute("fill", "#7c3aed");
192
- rect.setAttribute("filter", "url(#explainGlow)");
193
- g.appendChild(rect);
194
-
195
- const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
196
- text.setAttribute("text-anchor", "middle"); text.setAttribute("dy", "0.35em");
197
- text.setAttribute("fill", "white"); text.setAttribute("font-size", "11");
198
- text.setAttribute("font-weight", "700");
199
- text.textContent = d.name;
200
- if (d.confidence != null) text.textContent += ` ${Math.round(d.confidence * 100)}%`;
201
- g.appendChild(text);
202
- }
203
-
204
- function _drawCategoryNode(g, d) {
205
- const w = 90, h = 26, rx = 6;
206
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
207
- rect.setAttribute("x", -w / 2); rect.setAttribute("y", -h / 2);
208
- rect.setAttribute("width", w); rect.setAttribute("height", h);
209
- rect.setAttribute("rx", rx); rect.setAttribute("fill", d.color || "#3b82f6");
210
- rect.setAttribute("filter", "url(#explainGlow)"); rect.setAttribute("opacity", "0.95");
211
- g.appendChild(rect);
212
-
213
- const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
214
- text.setAttribute("text-anchor", "middle"); text.setAttribute("dy", "0.35em");
215
- text.setAttribute("fill", "white"); text.setAttribute("font-size", "9");
216
- text.setAttribute("font-weight", "600");
217
- text.textContent = d.name;
218
- g.appendChild(text);
219
- }
220
-
221
- function _drawFeatureNode(g, d) {
222
- const w = 80, h = 22, rx = 5;
223
- const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
224
- rect.setAttribute("x", -w / 2); rect.setAttribute("y", -h / 2);
225
- rect.setAttribute("width", w); rect.setAttribute("height", h);
226
- rect.setAttribute("rx", rx); rect.setAttribute("fill", "#0f172a");
227
- rect.setAttribute("stroke", d.color || "#3b82f6"); rect.setAttribute("stroke-width", "1.5");
228
- g.appendChild(rect);
229
-
230
- const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
231
- text.setAttribute("text-anchor", "middle"); text.setAttribute("dy", "0.35em");
232
- text.setAttribute("fill", _lightenColor(d.color || "#3b82f6")); text.setAttribute("font-size", "7.5");
233
- text.textContent = d.name.length > 12 ? d.name.slice(0, 11) + "\u2026" : d.name;
234
- g.appendChild(text);
235
-
236
- // Validator badge
237
- const total = Object.keys(d.validators || {}).length;
238
- if (total > 0) {
239
- const agreed = Object.values(d.validators).filter(v => v.agree).length;
240
- const badge = document.createElementNS("http://www.w3.org/2000/svg", "text");
241
- badge.setAttribute("x", w / 2 - 4); badge.setAttribute("y", -h / 2 - 3);
242
- badge.setAttribute("text-anchor", "end"); badge.setAttribute("font-size", "7");
243
- badge.setAttribute("fill", agreed === total ? "#4ade80" : "#f87171");
244
- badge.textContent = `${agreed}/${total}`;
245
- g.appendChild(badge);
246
- }
247
- }
248
-
249
- function _drawConsensusBar(svg, bar, width, height, margin) {
250
- // Positioned at the bottom of the SVG
251
- const barY = height - 30;
252
- const barW = width - 40;
253
- const barX = 20;
254
- const barH = 6;
255
- const ratio = bar.total_features > 0 ? bar.agreed / bar.total_features : 0;
256
-
257
- // Background
258
- const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
259
- bg.setAttribute("x", barX); bg.setAttribute("y", barY);
260
- bg.setAttribute("width", barW); bg.setAttribute("height", barH);
261
- bg.setAttribute("rx", 3); bg.setAttribute("fill", "#1e293b");
262
- svg.appendChild(bg);
263
-
264
- // Fill
265
- const fill = document.createElementNS("http://www.w3.org/2000/svg", "rect");
266
- fill.setAttribute("x", barX); fill.setAttribute("y", barY);
267
- fill.setAttribute("width", barW * ratio); fill.setAttribute("height", barH);
268
- fill.setAttribute("rx", 3); fill.setAttribute("fill", "#7c3aed");
269
- fill.setAttribute("opacity", "0.8"); fill.setAttribute("filter", "url(#explainGlow)");
270
- svg.appendChild(fill);
271
-
272
- // Label
273
- const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
274
- label.setAttribute("x", barX); label.setAttribute("y", barY + barH + 14);
275
- label.setAttribute("fill", "#a78bfa"); label.setAttribute("font-size", "9");
276
- label.textContent = `${bar.agreed}/${bar.total_features} features agreed`;
277
- svg.appendChild(label);
278
-
279
- const vLabel = document.createElementNS("http://www.w3.org/2000/svg", "text");
280
- vLabel.setAttribute("x", barX + barW); vLabel.setAttribute("y", barY + barH + 14);
281
- vLabel.setAttribute("text-anchor", "end");
282
- vLabel.setAttribute("fill", "#6b7280"); vLabel.setAttribute("font-size", "9");
283
- vLabel.textContent = `${bar.validators_available}/2 validators`;
284
- svg.appendChild(vLabel);
285
- }
286
-
287
- function _showTooltip(event, data, container) {
288
- _hideTooltip(container);
289
- if (!data.reasoning && !data.isCategory && !data.summary) return;
290
-
291
- const tip = document.createElement("div");
292
- tip.className = "explain-tooltip";
293
-
294
- if (data.isCategory) {
295
- // Category summary
296
- const children = data.children || [];
297
- const total = children.length;
298
- const allAgreed = children.filter(c => {
299
- const vals = Object.values(c.validators || {});
300
- return vals.length > 0 && vals.every(v => v.agree);
301
- }).length;
302
- tip.innerHTML = `<strong>${_escHtml(data.name)}</strong><br>${allAgreed}/${total} features fully validated`;
303
- } else if (data.isFeature) {
304
- // Feature detail
305
- let html = `<strong>${_escHtml(data.name)}</strong>`;
306
- if (data.reasoning) {
307
- html += `<div class="tip-section"><span class="tip-label">GPT-4o:</span> ${_escHtml(data.reasoning)}</div>`;
308
- }
309
- const validators = data.validators || {};
310
- if (validators.claude) {
311
- const icon = validators.claude.agree ? "\u2713" : "\u2717";
312
- const cls = validators.claude.agree ? "tip-agree" : "tip-disagree";
313
- html += `<div class="tip-section ${cls}"><span class="tip-label">${icon} Claude:</span> ${_escHtml(validators.claude.note || "")}</div>`;
314
- }
315
- if (validators.gemini) {
316
- const icon = validators.gemini.agree ? "\u2713" : "\u2717";
317
- const cls = validators.gemini.agree ? "tip-agree" : "tip-disagree";
318
- html += `<div class="tip-section ${cls}"><span class="tip-label">${icon} Gemini:</span> ${_escHtml(validators.gemini.note || "")}</div>`;
319
- }
320
- tip.innerHTML = html;
321
- } else if (data.summary) {
322
- tip.innerHTML = `<strong>${_escHtml(data.name)}</strong><br>${_escHtml(data.summary)}`;
323
- }
324
-
325
- // Position near the hovered element
326
- const rect = event.target.closest("g").getBoundingClientRect();
327
- const containerRect = container.getBoundingClientRect();
328
- let left = rect.left - containerRect.left + rect.width / 2;
329
- let top = rect.top - containerRect.top - 8;
330
-
331
- tip.style.left = left + "px";
332
- tip.style.top = top + "px";
333
- tip.style.transform = "translate(-50%, -100%)";
334
-
335
- container.appendChild(tip);
336
-
337
- // Clamp to container bounds
338
- const tipRect = tip.getBoundingClientRect();
339
- if (tipRect.left < containerRect.left) {
340
- tip.style.left = (left + (containerRect.left - tipRect.left) + 4) + "px";
341
- }
342
- if (tipRect.right > containerRect.right) {
343
- tip.style.left = (left - (tipRect.right - containerRect.right) - 4) + "px";
344
- }
345
- }
346
-
347
- function _hideTooltip(container) {
348
- const old = container.querySelector(".explain-tooltip");
349
- if (old) old.remove();
350
- }
351
-
352
- function _escHtml(str) {
353
- const div = document.createElement("div");
354
- div.textContent = str || "";
355
- return div.innerHTML;
356
- }
357
-
358
- function _lightenColor(hex) {
359
- // Return a lighter version for text on dark backgrounds
360
- const map = {
361
- "#3b82f6": "#93c5fd", "#06b6d4": "#a5f3fc", "#f59e0b": "#fde68a",
362
- "#ef4444": "#fca5a5", "#10b981": "#6ee7b7", "#8b5cf6": "#c4b5fd",
363
- "#ec4899": "#f9a8d4", "#64748b": "#94a3b8", "#f97316": "#fdba74",
364
- "#a855f7": "#d8b4fe",
365
- };
366
- return map[hex] || "#e2e8f0";
367
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/inspection-3d.js DELETED
@@ -1,474 +0,0 @@
1
- // Inspection 3D Viewer — Three.js mesh + point cloud renderer
2
- APP.ui.inspection3d = {};
3
-
4
- // Internal state
5
- APP.ui.inspection3d._scene = null;
6
- APP.ui.inspection3d._camera = null;
7
- APP.ui.inspection3d._renderer = null;
8
- APP.ui.inspection3d._controls = null;
9
- APP.ui.inspection3d._animFrameId = null;
10
- APP.ui.inspection3d._object = null;
11
- APP.ui.inspection3d._label = null;
12
- APP.ui.inspection3d._disposed = false;
13
-
14
- /**
15
- * Load Three.js and OrbitControls (once).
16
- */
17
- APP.ui.inspection3d._ensureThreeJs = async function () {
18
- const { loadScriptOnce } = APP.core.utils;
19
- await loadScriptOnce("three", "/app/vendor/three.min.js");
20
- await loadScriptOnce("three-orbit", "/app/vendor/OrbitControls.js");
21
- await loadScriptOnce("three-gltf", "/app/vendor/GLTFLoader.js");
22
- };
23
-
24
- /**
25
- * Render 3D data in the inspection container.
26
- * Supports both mesh (with indices) and point cloud modes.
27
- * @param {Object} data — from fetchPointCloud
28
- */
29
- APP.ui.inspection3d.render = async function (data) {
30
- if (!data || !data.positions || !data.colors) return;
31
-
32
- const { $ } = APP.core.utils;
33
- const container = $("#quad3dContainer");
34
- if (!container) return;
35
-
36
- try {
37
- await APP.ui.inspection3d._ensureThreeJs();
38
- } catch (err) {
39
- console.error("Failed to load 3D libraries:", err);
40
- return;
41
- }
42
-
43
- APP.ui.inspection3d.dispose();
44
- APP.ui.inspection3d._disposed = false;
45
-
46
- const rect = container.getBoundingClientRect();
47
- const width = rect.width || 600;
48
- const height = rect.height || 350;
49
- const isMesh = data.renderMode === "mesh" && data.indices && data.indices.length > 0;
50
-
51
- // ── Scene ──
52
- const scene = new THREE.Scene();
53
- scene.background = new THREE.Color(0x0a0f1e);
54
-
55
- // ── Camera ──
56
- const camera = new THREE.PerspectiveCamera(50, width / height, 0.01, 500);
57
-
58
- // ── Renderer ──
59
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
60
- renderer.setSize(width, height);
61
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
62
- container.appendChild(renderer.domElement);
63
-
64
- // ── OrbitControls ──
65
- const controls = new THREE.OrbitControls(camera, renderer.domElement);
66
- controls.enableDamping = true;
67
- controls.dampingFactor = 0.08;
68
- controls.rotateSpeed = 0.8;
69
- controls.zoomSpeed = 1.0;
70
- controls.panSpeed = 0.6;
71
- controls.minDistance = 0.1;
72
- controls.maxDistance = 50;
73
-
74
- // ── Geometry ──
75
- const geometry = new THREE.BufferGeometry();
76
- geometry.setAttribute("position", new THREE.Float32BufferAttribute(data.positions, 3));
77
-
78
- // Normalize colors to [0,1]
79
- const colorFloats = new Float32Array(data.colors.length);
80
- for (let i = 0; i < data.colors.length; i++) {
81
- colorFloats[i] = data.colors[i] / 255.0;
82
- }
83
- geometry.setAttribute("color", new THREE.Float32BufferAttribute(colorFloats, 3));
84
-
85
- // Center geometry
86
- geometry.computeBoundingBox();
87
- const bbox = geometry.boundingBox;
88
- const center = new THREE.Vector3();
89
- bbox.getCenter(center);
90
- geometry.translate(-center.x, -center.y, -center.z);
91
-
92
- // Normalize scale — depth is arbitrary, scale to ~4 units
93
- const rawSize = new THREE.Vector3();
94
- bbox.getSize(rawSize);
95
- const rawMaxDim = Math.max(rawSize.x, rawSize.y, rawSize.z) || 1;
96
- const scaleFactor = 4.0 / rawMaxDim;
97
- geometry.scale(scaleFactor, scaleFactor, scaleFactor);
98
-
99
- let object3d;
100
-
101
- if (isMesh) {
102
- // ── Triangle mesh mode ──
103
- geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
104
- geometry.computeVertexNormals();
105
-
106
- const material = new THREE.MeshPhongMaterial({
107
- vertexColors: true,
108
- side: THREE.DoubleSide,
109
- shininess: 25,
110
- specular: new THREE.Color(0x222222),
111
- flatShading: false
112
- });
113
-
114
- object3d = new THREE.Mesh(geometry, material);
115
-
116
- // Better lighting for mesh
117
- const ambient = new THREE.AmbientLight(0xffffff, 0.5);
118
- scene.add(ambient);
119
-
120
- const keyLight = new THREE.DirectionalLight(0xffffff, 0.7);
121
- keyLight.position.set(5, 5, 5);
122
- scene.add(keyLight);
123
-
124
- const fillLight = new THREE.DirectionalLight(0x8888cc, 0.3);
125
- fillLight.position.set(-4, 2, -3);
126
- scene.add(fillLight);
127
-
128
- const rimLight = new THREE.DirectionalLight(0x4444ff, 0.2);
129
- rimLight.position.set(0, -3, -5);
130
- scene.add(rimLight);
131
- } else {
132
- // ── Point cloud fallback ──
133
- const numPts = data.numVertices || data.positions.length / 3;
134
- const ptSize = numPts > 30000 ? 0.02 : numPts > 10000 ? 0.03 : 0.05;
135
- const material = new THREE.PointsMaterial({
136
- size: ptSize,
137
- vertexColors: true,
138
- sizeAttenuation: true,
139
- transparent: true,
140
- opacity: 0.9
141
- });
142
- object3d = new THREE.Points(geometry, material);
143
-
144
- const ambient = new THREE.AmbientLight(0xffffff, 1.0);
145
- scene.add(ambient);
146
- }
147
-
148
- scene.add(object3d);
149
-
150
- // Subtle fog
151
- scene.fog = new THREE.FogExp2(0x0a0f1e, 0.03);
152
-
153
- // ── Camera positioning ──
154
- geometry.computeBoundingBox();
155
- const scaledBbox = geometry.boundingBox;
156
- const scaledSize = new THREE.Vector3();
157
- scaledBbox.getSize(scaledSize);
158
- const maxDim = Math.max(scaledSize.x, scaledSize.y, scaledSize.z) || 1;
159
-
160
- const camDist = maxDim * 1.6;
161
- camera.position.set(camDist * 0.7, camDist * 0.4, camDist * 0.9);
162
- camera.lookAt(0, 0, 0);
163
- controls.target.set(0, 0, 0);
164
- controls.update();
165
-
166
- // Subtle grid
167
- const gridHelper = new THREE.GridHelper(maxDim * 2, 20, 0x333366, 0x1a1a3e);
168
- gridHelper.position.y = scaledBbox.min.y;
169
- scene.add(gridHelper);
170
-
171
- // ── Info labels ──
172
- const numV = data.numVertices || data.positions.length / 3;
173
- const modeTag = isMesh ? "MESH" : "POINT CLOUD";
174
- let labelText = `${modeTag} ${numV.toLocaleString()} ${isMesh ? "verts" : "pts"}`;
175
- if (isMesh && data.numTriangles) {
176
- labelText += ` / ${data.numTriangles.toLocaleString()} tris`;
177
- }
178
- if (data.bbox3d) {
179
- const b = data.bbox3d;
180
- const dims = [
181
- (b.max[0] - b.min[0]).toFixed(1),
182
- (b.max[1] - b.min[1]).toFixed(1),
183
- (b.max[2] - b.min[2]).toFixed(1)
184
- ];
185
- labelText += ` | ${dims[0]} x ${dims[1]} x ${dims[2]}`;
186
- }
187
-
188
- const label = document.createElement("div");
189
- label.style.cssText = [
190
- "position: absolute", "top: 8px", "left: 8px",
191
- "padding: 4px 8px", "background: rgba(0,0,0,0.6)",
192
- "border: 1px solid rgba(255,255,255,0.15)", "border-radius: 6px",
193
- "font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace",
194
- "color: rgba(255,255,255,0.7)", "pointer-events: none", "z-index: 10"
195
- ].join("; ");
196
- label.textContent = labelText;
197
- container.appendChild(label);
198
-
199
- const hint = document.createElement("div");
200
- hint.style.cssText = [
201
- "position: absolute", "bottom: 8px", "right: 8px",
202
- "padding: 4px 8px", "background: rgba(0,0,0,0.5)",
203
- "border: 1px solid rgba(255,255,255,0.10)", "border-radius: 6px",
204
- "font: 9px ui-monospace, SFMono-Regular, Menlo, monospace",
205
- "color: rgba(255,255,255,0.45)", "pointer-events: none", "z-index: 10"
206
- ].join("; ");
207
- hint.textContent = "Drag to rotate | Scroll to zoom | Right-drag to pan";
208
- container.appendChild(hint);
209
-
210
- // ── Store refs ──
211
- APP.ui.inspection3d._scene = scene;
212
- APP.ui.inspection3d._camera = camera;
213
- APP.ui.inspection3d._renderer = renderer;
214
- APP.ui.inspection3d._controls = controls;
215
- APP.ui.inspection3d._object = object3d;
216
- APP.ui.inspection3d._label = label;
217
- APP.ui.inspection3d._hint = hint;
218
-
219
- // ── Resize handler ──
220
- APP.ui.inspection3d._resizeHandler = function () {
221
- if (APP.ui.inspection3d._disposed) return;
222
- const r = container.getBoundingClientRect();
223
- const w = r.width || 600;
224
- const h = r.height || 350;
225
- camera.aspect = w / h;
226
- camera.updateProjectionMatrix();
227
- renderer.setSize(w, h);
228
- };
229
- window.addEventListener("resize", APP.ui.inspection3d._resizeHandler);
230
-
231
- // ── Animation loop ──
232
- function animate() {
233
- if (APP.ui.inspection3d._disposed) return;
234
- APP.ui.inspection3d._animFrameId = requestAnimationFrame(animate);
235
- controls.update();
236
- renderer.render(scene, camera);
237
- }
238
- animate();
239
- };
240
-
241
- /**
242
- * Render a GLB model from Tripo3D in the inspection container.
243
- * @param {ArrayBuffer} arrayBuffer — raw GLB binary
244
- */
245
- APP.ui.inspection3d.renderGLB = async function (arrayBuffer) {
246
- const { $ } = APP.core.utils;
247
- const container = $("#quad3dContainer");
248
- if (!container) return;
249
-
250
- try {
251
- await APP.ui.inspection3d._ensureThreeJs();
252
- } catch (err) {
253
- console.error("Failed to load 3D libraries:", err);
254
- return;
255
- }
256
-
257
- APP.ui.inspection3d.dispose();
258
- APP.ui.inspection3d._disposed = false;
259
-
260
- var rect = container.getBoundingClientRect();
261
- var width = rect.width || 600;
262
- var height = rect.height || 350;
263
-
264
- // ── Scene ──
265
- var scene = new THREE.Scene();
266
- scene.background = new THREE.Color(0x0a0f1e);
267
-
268
- // ── Camera ──
269
- var camera = new THREE.PerspectiveCamera(50, width / height, 0.01, 500);
270
-
271
- // ── Renderer ──
272
- var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
273
- renderer.setSize(width, height);
274
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
275
- container.appendChild(renderer.domElement);
276
-
277
- // ── OrbitControls ──
278
- var controls = new THREE.OrbitControls(camera, renderer.domElement);
279
- controls.enableDamping = true;
280
- controls.dampingFactor = 0.08;
281
- controls.rotateSpeed = 0.8;
282
- controls.zoomSpeed = 1.0;
283
- controls.panSpeed = 0.6;
284
- controls.minDistance = 0.1;
285
- controls.maxDistance = 50;
286
-
287
- // ── Lights (same 3-light rig as mesh mode) ──
288
- scene.add(new THREE.AmbientLight(0xffffff, 0.5));
289
- var keyLight = new THREE.DirectionalLight(0xffffff, 0.7);
290
- keyLight.position.set(5, 5, 5);
291
- scene.add(keyLight);
292
- var fillLight = new THREE.DirectionalLight(0x8888cc, 0.3);
293
- fillLight.position.set(-4, 2, -3);
294
- scene.add(fillLight);
295
- var rimLight = new THREE.DirectionalLight(0x4444ff, 0.2);
296
- rimLight.position.set(0, -3, -5);
297
- scene.add(rimLight);
298
-
299
- // ── Parse GLB ──
300
- var loader = new THREE.GLTFLoader();
301
- var gltf = await new Promise(function (resolve, reject) {
302
- loader.parse(arrayBuffer, "", resolve, reject);
303
- });
304
-
305
- var model = gltf.scene;
306
- scene.add(model);
307
-
308
- // ── Center and normalize scale ──
309
- var box = new THREE.Box3().setFromObject(model);
310
- var center = new THREE.Vector3();
311
- box.getCenter(center);
312
- model.position.sub(center);
313
-
314
- var size = new THREE.Vector3();
315
- box.getSize(size);
316
- var maxDim = Math.max(size.x, size.y, size.z) || 1;
317
- var scaleFactor = 4.0 / maxDim;
318
- model.scale.setScalar(scaleFactor);
319
-
320
- // Recompute after scaling
321
- var scaledBox = new THREE.Box3().setFromObject(model);
322
- var scaledSize = new THREE.Vector3();
323
- scaledBox.getSize(scaledSize);
324
- var scaledMax = Math.max(scaledSize.x, scaledSize.y, scaledSize.z) || 1;
325
-
326
- // ── Camera positioning ──
327
- var camDist = scaledMax * 1.6;
328
- camera.position.set(camDist * 0.7, camDist * 0.4, camDist * 0.9);
329
- camera.lookAt(0, 0, 0);
330
- controls.target.set(0, 0, 0);
331
- controls.update();
332
-
333
- // ── Grid ──
334
- var gridHelper = new THREE.GridHelper(scaledMax * 2, 20, 0x333366, 0x1a1a3e);
335
- gridHelper.position.y = scaledBox.min.y;
336
- scene.add(gridHelper);
337
-
338
- // ── Fog ──
339
- scene.fog = new THREE.FogExp2(0x0a0f1e, 0.03);
340
-
341
- // ── Count vertices for label ──
342
- var totalVerts = 0;
343
- model.traverse(function (child) {
344
- if (child.isMesh && child.geometry) {
345
- var pos = child.geometry.getAttribute("position");
346
- if (pos) totalVerts += pos.count;
347
- }
348
- });
349
-
350
- // ── Label ──
351
- var label = document.createElement("div");
352
- label.style.cssText = [
353
- "position: absolute", "top: 8px", "left: 8px",
354
- "padding: 4px 8px", "background: rgba(0,0,0,0.6)",
355
- "border: 1px solid rgba(255,255,255,0.15)", "border-radius: 6px",
356
- "font: 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace",
357
- "color: rgba(255,255,255,0.7)", "pointer-events: none", "z-index: 10"
358
- ].join("; ");
359
- label.textContent = "GENERATIVE 3D " + totalVerts.toLocaleString() + " verts";
360
- container.appendChild(label);
361
-
362
- // ── Hint ──
363
- var hint = document.createElement("div");
364
- hint.style.cssText = [
365
- "position: absolute", "bottom: 8px", "right: 8px",
366
- "padding: 4px 8px", "background: rgba(0,0,0,0.5)",
367
- "border: 1px solid rgba(255,255,255,0.10)", "border-radius: 6px",
368
- "font: 9px ui-monospace, SFMono-Regular, Menlo, monospace",
369
- "color: rgba(255,255,255,0.45)", "pointer-events: none", "z-index: 10"
370
- ].join("; ");
371
- hint.textContent = "Drag to rotate | Scroll to zoom | Right-drag to pan";
372
- container.appendChild(hint);
373
-
374
- // ── Store refs ──
375
- APP.ui.inspection3d._scene = scene;
376
- APP.ui.inspection3d._camera = camera;
377
- APP.ui.inspection3d._renderer = renderer;
378
- APP.ui.inspection3d._controls = controls;
379
- APP.ui.inspection3d._object = model;
380
- APP.ui.inspection3d._label = label;
381
- APP.ui.inspection3d._hint = hint;
382
-
383
- // ── Resize handler ──
384
- APP.ui.inspection3d._resizeHandler = function () {
385
- if (APP.ui.inspection3d._disposed) return;
386
- var r = container.getBoundingClientRect();
387
- var w = r.width || 600;
388
- var h = r.height || 350;
389
- camera.aspect = w / h;
390
- camera.updateProjectionMatrix();
391
- renderer.setSize(w, h);
392
- };
393
- window.addEventListener("resize", APP.ui.inspection3d._resizeHandler);
394
-
395
- // ── Animation loop ──
396
- function animate() {
397
- if (APP.ui.inspection3d._disposed) return;
398
- APP.ui.inspection3d._animFrameId = requestAnimationFrame(animate);
399
- controls.update();
400
- renderer.render(scene, camera);
401
- }
402
- animate();
403
- };
404
-
405
- /**
406
- * Dispose of all Three.js resources and clean up DOM.
407
- */
408
- APP.ui.inspection3d.dispose = function () {
409
- APP.ui.inspection3d._disposed = true;
410
-
411
- if (APP.ui.inspection3d._animFrameId) {
412
- cancelAnimationFrame(APP.ui.inspection3d._animFrameId);
413
- APP.ui.inspection3d._animFrameId = null;
414
- }
415
-
416
- if (APP.ui.inspection3d._resizeHandler) {
417
- window.removeEventListener("resize", APP.ui.inspection3d._resizeHandler);
418
- APP.ui.inspection3d._resizeHandler = null;
419
- }
420
-
421
- if (APP.ui.inspection3d._controls) {
422
- APP.ui.inspection3d._controls.dispose();
423
- APP.ui.inspection3d._controls = null;
424
- }
425
-
426
- if (APP.ui.inspection3d._object) {
427
- if (APP.ui.inspection3d._object.geometry) {
428
- APP.ui.inspection3d._object.geometry.dispose();
429
- }
430
- if (APP.ui.inspection3d._object.material) {
431
- APP.ui.inspection3d._object.material.dispose();
432
- }
433
- APP.ui.inspection3d._object = null;
434
- }
435
-
436
- if (APP.ui.inspection3d._renderer) {
437
- APP.ui.inspection3d._renderer.dispose();
438
- const canvas = APP.ui.inspection3d._renderer.domElement;
439
- if (canvas && canvas.parentElement) {
440
- canvas.parentElement.removeChild(canvas);
441
- }
442
- APP.ui.inspection3d._renderer = null;
443
- }
444
-
445
- if (APP.ui.inspection3d._label && APP.ui.inspection3d._label.parentElement) {
446
- APP.ui.inspection3d._label.parentElement.removeChild(APP.ui.inspection3d._label);
447
- }
448
- APP.ui.inspection3d._label = null;
449
-
450
- if (APP.ui.inspection3d._hint && APP.ui.inspection3d._hint.parentElement) {
451
- APP.ui.inspection3d._hint.parentElement.removeChild(APP.ui.inspection3d._hint);
452
- }
453
- APP.ui.inspection3d._hint = null;
454
-
455
- if (APP.ui.inspection3d._scene) {
456
- APP.ui.inspection3d._scene.traverse(function (obj) {
457
- if (obj.geometry) obj.geometry.dispose();
458
- if (obj.material) {
459
- var mats = Array.isArray(obj.material) ? obj.material : [obj.material];
460
- mats.forEach(function (m) {
461
- if (m.map) m.map.dispose();
462
- if (m.normalMap) m.normalMap.dispose();
463
- if (m.roughnessMap) m.roughnessMap.dispose();
464
- if (m.metalnessMap) m.metalnessMap.dispose();
465
- if (m.emissiveMap) m.emissiveMap.dispose();
466
- m.dispose();
467
- });
468
- }
469
- });
470
- APP.ui.inspection3d._scene = null;
471
- }
472
-
473
- APP.ui.inspection3d._camera = null;
474
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/inspection-renders.js DELETED
@@ -1,446 +0,0 @@
1
- // Inspection Renderers — per-mode canvas drawing functions
2
- APP.ui.inspectionRenders = {};
3
-
4
- /**
5
- * Render segmentation mask overlay on the frame.
6
- * Shows the full frame dimmed, with the selected object's mask highlighted.
7
- */
8
- APP.ui.inspectionRenders._renderSeg = function (canvas, frameImg, maskData, track) {
9
- if (!frameImg) return;
10
-
11
- const w = frameImg.naturalWidth || frameImg.width;
12
- const h = frameImg.naturalHeight || frameImg.height;
13
- canvas.width = w;
14
- canvas.height = h;
15
- const ctx = canvas.getContext("2d");
16
-
17
- // Draw base frame (dimmed)
18
- ctx.drawImage(frameImg, 0, 0);
19
- ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
20
- ctx.fillRect(0, 0, w, h);
21
-
22
- if (!maskData) return;
23
-
24
- // Compute the crop region matching backend's crop_frame(bbox, padding=0.20)
25
- // The frame image is cropped, but the mask is in full-frame coordinates.
26
- const _cropRegion = (fullW, fullH) => {
27
- const padding = 0.20;
28
- let bx1, by1, bx2, by2;
29
- if (maskData.bbox) {
30
- [bx1, by1, bx2, by2] = maskData.bbox;
31
- } else if (track && track.bbox) {
32
- bx1 = track.bbox.x * fullW;
33
- by1 = track.bbox.y * fullH;
34
- bx2 = (track.bbox.x + track.bbox.w) * fullW;
35
- by2 = (track.bbox.y + track.bbox.h) * fullH;
36
- } else {
37
- return null;
38
- }
39
- const bw = bx2 - bx1, bh = by2 - by1;
40
- const padX = Math.floor(bw * padding), padY = Math.floor(bh * padding);
41
- return {
42
- cx1: Math.max(0, bx1 - padX), cy1: Math.max(0, by1 - padY),
43
- cx2: Math.min(fullW, bx2 + padX), cy2: Math.min(fullH, by2 + padY),
44
- };
45
- };
46
-
47
- const color = maskData.color || [59, 130, 246];
48
-
49
- if (maskData.rle) {
50
- const rle = maskData.rle;
51
- const maskH = rle.size[0];
52
- const maskW = rle.size[1];
53
-
54
- // Decode RLE to flat boolean array (column-major, COCO format)
55
- const mask = new Uint8Array(maskH * maskW);
56
- let pos = 0;
57
- let val = 0;
58
- for (const count of rle.counts) {
59
- for (let i = 0; i < count && pos < mask.length; i++) {
60
- mask[pos++] = val;
61
- }
62
- val = 1 - val;
63
- }
64
-
65
- const crop = _cropRegion(maskW, maskH);
66
-
67
- const frameCanvas = document.createElement("canvas");
68
- frameCanvas.width = w;
69
- frameCanvas.height = h;
70
- const fctx = frameCanvas.getContext("2d");
71
- fctx.drawImage(frameImg, 0, 0);
72
- const framePixels = fctx.getImageData(0, 0, w, h);
73
-
74
- const outPixels = ctx.getImageData(0, 0, w, h);
75
- const out = outPixels.data;
76
- const fd = framePixels.data;
77
-
78
- for (let py = 0; py < h; py++) {
79
- for (let px = 0; px < w; px++) {
80
- let mx, my;
81
- if (crop) {
82
- // Map cropped canvas pixel back to full-frame coordinates
83
- mx = Math.floor(crop.cx1 + px * (crop.cx2 - crop.cx1) / w);
84
- my = Math.floor(crop.cy1 + py * (crop.cy2 - crop.cy1) / h);
85
- } else {
86
- mx = Math.floor(px * maskW / w);
87
- my = Math.floor(py * maskH / h);
88
- }
89
- // COCO RLE is column-major: index = x * height + y
90
- const maskIdx = mx * maskH + my;
91
-
92
- if (maskIdx >= 0 && maskIdx < mask.length && mask[maskIdx]) {
93
- const i = (py * w + px) * 4;
94
- out[i] = Math.round(fd[i] * 0.5 + color[0] * 0.5);
95
- out[i + 1] = Math.round(fd[i + 1] * 0.5 + color[1] * 0.5);
96
- out[i + 2] = Math.round(fd[i + 2] * 0.5 + color[2] * 0.5);
97
- out[i + 3] = 255;
98
- }
99
- }
100
- }
101
- ctx.putImageData(outPixels, 0, 0);
102
-
103
- } else if (maskData._maskImage) {
104
- // PNG mask: full-frame sized, must map through crop region
105
- const maskImgW = maskData._maskImage.naturalWidth || maskData._maskImage.width;
106
- const maskImgH = maskData._maskImage.naturalHeight || maskData._maskImage.height;
107
-
108
- const maskCanvas = document.createElement("canvas");
109
- maskCanvas.width = maskImgW;
110
- maskCanvas.height = maskImgH;
111
- const mctx = maskCanvas.getContext("2d");
112
- mctx.drawImage(maskData._maskImage, 0, 0);
113
- const maskPixels = mctx.getImageData(0, 0, maskImgW, maskImgH);
114
-
115
- const crop = _cropRegion(maskImgW, maskImgH);
116
-
117
- const frameCanvas = document.createElement("canvas");
118
- frameCanvas.width = w;
119
- frameCanvas.height = h;
120
- const fctx = frameCanvas.getContext("2d");
121
- fctx.drawImage(frameImg, 0, 0);
122
- const framePixels = fctx.getImageData(0, 0, w, h);
123
-
124
- const outPixels = ctx.getImageData(0, 0, w, h);
125
- const out = outPixels.data;
126
- const md = maskPixels.data;
127
- const fd = framePixels.data;
128
-
129
- for (let py = 0; py < h; py++) {
130
- for (let px = 0; px < w; px++) {
131
- let fullX, fullY;
132
- if (crop) {
133
- fullX = Math.floor(crop.cx1 + px * (crop.cx2 - crop.cx1) / w);
134
- fullY = Math.floor(crop.cy1 + py * (crop.cy2 - crop.cy1) / h);
135
- } else {
136
- fullX = Math.floor(px * maskImgW / w);
137
- fullY = Math.floor(py * maskImgH / h);
138
- }
139
- if (fullX >= 0 && fullX < maskImgW && fullY >= 0 && fullY < maskImgH) {
140
- const mi = (fullY * maskImgW + fullX) * 4;
141
- const maskVal = md[mi + 3] > 0 ? 1 : (md[mi] > 128 ? 1 : 0);
142
- if (maskVal) {
143
- const i = (py * w + px) * 4;
144
- out[i] = Math.round(fd[i] * 0.5 + color[0] * 0.5);
145
- out[i + 1] = Math.round(fd[i + 1] * 0.5 + color[1] * 0.5);
146
- out[i + 2] = Math.round(fd[i + 2] * 0.5 + color[2] * 0.5);
147
- out[i + 3] = 255;
148
- }
149
- }
150
- }
151
- }
152
- ctx.putImageData(outPixels, 0, 0);
153
- }
154
- };
155
-
156
- /**
157
- * Compute Sobel edge detection from a frame image (pure frontend).
158
- * @param {HTMLImageElement} frameImg
159
- * @returns {ImageData} the edge-detected image
160
- */
161
- APP.ui.inspectionRenders.computeEdge = function (frameImg) {
162
- const w = frameImg.naturalWidth || frameImg.width;
163
- const h = frameImg.naturalHeight || frameImg.height;
164
-
165
- const tmpCanvas = document.createElement("canvas");
166
- tmpCanvas.width = w;
167
- tmpCanvas.height = h;
168
- const tmpCtx = tmpCanvas.getContext("2d");
169
- tmpCtx.drawImage(frameImg, 0, 0);
170
- const src = tmpCtx.getImageData(0, 0, w, h);
171
- const d = src.data;
172
-
173
- // Convert to grayscale
174
- const gray = new Float32Array(w * h);
175
- for (let i = 0; i < w * h; i++) {
176
- const idx = i * 4;
177
- gray[i] = 0.299 * d[idx] + 0.587 * d[idx + 1] + 0.114 * d[idx + 2];
178
- }
179
-
180
- // Sobel kernels
181
- const out = new ImageData(w, h);
182
- const od = out.data;
183
-
184
- for (let y = 1; y < h - 1; y++) {
185
- for (let x = 1; x < w - 1; x++) {
186
- const idx = y * w + x;
187
-
188
- // Gx = Sobel horizontal
189
- const gx =
190
- -gray[(y - 1) * w + (x - 1)] + gray[(y - 1) * w + (x + 1)]
191
- - 2 * gray[y * w + (x - 1)] + 2 * gray[y * w + (x + 1)]
192
- - gray[(y + 1) * w + (x - 1)] + gray[(y + 1) * w + (x + 1)];
193
-
194
- // Gy = Sobel vertical
195
- const gy =
196
- -gray[(y - 1) * w + (x - 1)] - 2 * gray[(y - 1) * w + x] - gray[(y - 1) * w + (x + 1)]
197
- + gray[(y + 1) * w + (x - 1)] + 2 * gray[(y + 1) * w + x] + gray[(y + 1) * w + (x + 1)];
198
-
199
- const mag = Math.min(255, Math.sqrt(gx * gx + gy * gy));
200
-
201
- const oi = idx * 4;
202
- // Render edges as cyan on black (matches design system)
203
- od[oi] = Math.round(mag * 0.13); // R (slight tint)
204
- od[oi + 1] = Math.round(mag * 0.83); // G
205
- od[oi + 2] = Math.round(mag * 0.93); // B (cyan)
206
- od[oi + 3] = 255;
207
- }
208
- }
209
-
210
- return out;
211
- };
212
-
213
- /**
214
- * Render edge detection result.
215
- */
216
- APP.ui.inspectionRenders._renderEdge = function (canvas, frameImg, edgeData, track) {
217
- if (!frameImg) return;
218
-
219
- const w = frameImg.naturalWidth || frameImg.width;
220
- const h = frameImg.naturalHeight || frameImg.height;
221
- canvas.width = w;
222
- canvas.height = h;
223
- const ctx = canvas.getContext("2d");
224
-
225
- if (edgeData) {
226
- ctx.putImageData(edgeData, 0, 0);
227
- } else {
228
- ctx.drawImage(frameImg, 0, 0);
229
- }
230
-
231
- APP.ui.inspectionRenders._drawBBoxHighlight(ctx, track, w, h);
232
- };
233
-
234
- /**
235
- * Render depth colormap (viridis-like palette) with scale legend.
236
- */
237
- APP.ui.inspectionRenders._renderDepth = function (canvas, frameImg, depthData, track) {
238
- if (!frameImg) return;
239
-
240
- const w = frameImg.naturalWidth || frameImg.width;
241
- const h = frameImg.naturalHeight || frameImg.height;
242
- canvas.width = w;
243
- canvas.height = h;
244
- const ctx = canvas.getContext("2d");
245
-
246
- if (!depthData || !depthData.data) {
247
- // No depth — show frame with message
248
- ctx.drawImage(frameImg, 0, 0);
249
- ctx.fillStyle = "rgba(0,0,0,0.6)";
250
- ctx.fillRect(0, 0, w, h);
251
- ctx.fillStyle = "#aaa";
252
- ctx.font = "14px monospace";
253
- ctx.textAlign = "center";
254
- ctx.fillText("Depth data not available", w / 2, h / 2);
255
- return;
256
- }
257
-
258
- // Apply viridis-like colormap to depth values
259
- const dw = depthData.width;
260
- const dh = depthData.height;
261
- const dd = depthData.data;
262
- const minD = depthData.min;
263
- const maxD = depthData.max;
264
- const range = maxD - minD || 1;
265
-
266
- const depthCanvas = document.createElement("canvas");
267
- depthCanvas.width = dw;
268
- depthCanvas.height = dh;
269
- const dctx = depthCanvas.getContext("2d");
270
- const img = dctx.createImageData(dw, dh);
271
- const id = img.data;
272
-
273
- for (let i = 0; i < dd.length; i++) {
274
- const val = dd[i];
275
- // Handle NaN/Inf values — render as black
276
- if (!isFinite(val)) {
277
- const oi = i * 4;
278
- id[oi] = 0; id[oi + 1] = 0; id[oi + 2] = 0; id[oi + 3] = 255;
279
- continue;
280
- }
281
- const t = APP.core.utils.clamp((val - minD) / range, 0, 1);
282
- const rgb = APP.ui.inspectionRenders._viridis(t);
283
- const oi = i * 4;
284
- id[oi] = rgb[0];
285
- id[oi + 1] = rgb[1];
286
- id[oi + 2] = rgb[2];
287
- id[oi + 3] = 255;
288
- }
289
-
290
- dctx.putImageData(img, 0, 0);
291
-
292
- // Draw scaled to canvas size
293
- ctx.drawImage(depthCanvas, 0, 0, w, h);
294
-
295
- // Draw bbox highlight for the selected track
296
- APP.ui.inspectionRenders._drawBBoxHighlight(ctx, track, w, h);
297
-
298
- // Draw depth scale legend (vertical gradient bar on the right)
299
- APP.ui.inspectionRenders._drawDepthLegend(ctx, w, h, minD, maxD);
300
-
301
- // If track is selected, compute and show average depth in the bbox region
302
- if (track && track.bbox && dw > 0 && dh > 0) {
303
- APP.ui.inspectionRenders._drawTrackDepthStats(ctx, track, w, h, depthData);
304
- }
305
- };
306
-
307
- /**
308
- * Draw a vertical depth scale legend on the right side of the canvas.
309
- */
310
- APP.ui.inspectionRenders._drawDepthLegend = function (ctx, canvasW, canvasH, minD, maxD) {
311
- const barW = 16;
312
- const barH = Math.min(180, canvasH - 60);
313
- const x = canvasW - barW - 30;
314
- const y = 30;
315
-
316
- // Background panel
317
- ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
318
- ctx.fillRect(x - 6, y - 20, barW + 52, barH + 40);
319
- ctx.strokeStyle = "rgba(255, 255, 255, 0.15)";
320
- ctx.lineWidth = 1;
321
- ctx.strokeRect(x - 6, y - 20, barW + 52, barH + 40);
322
-
323
- // Draw gradient bar
324
- for (let py = 0; py < barH; py++) {
325
- const t = py / (barH - 1); // 0 = top (near/min), 1 = bottom (far/max)
326
- const rgb = APP.ui.inspectionRenders._viridis(t);
327
- ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
328
- ctx.fillRect(x, y + py, barW, 1);
329
- }
330
-
331
- // Border around bar
332
- ctx.strokeStyle = "rgba(255, 255, 255, 0.3)";
333
- ctx.lineWidth = 1;
334
- ctx.strokeRect(x, y, barW, barH);
335
-
336
- // Labels
337
- ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
338
- ctx.font = "10px monospace";
339
- ctx.textAlign = "left";
340
- ctx.fillText(`${minD.toFixed(1)}m`, x + barW + 4, y + 10);
341
- ctx.fillText(`${maxD.toFixed(1)}m`, x + barW + 4, y + barH);
342
-
343
- // Title
344
- ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
345
- ctx.font = "9px monospace";
346
- ctx.textAlign = "center";
347
- ctx.fillText("DEPTH", x + barW / 2, y - 6);
348
- };
349
-
350
- /**
351
- * Compute and display average depth within the selected track's bounding box.
352
- */
353
- APP.ui.inspectionRenders._drawTrackDepthStats = function (ctx, track, canvasW, canvasH, depthData) {
354
- const b = track.bbox;
355
- const dw = depthData.width;
356
- const dh = depthData.height;
357
- const dd = depthData.data;
358
-
359
- // Map normalized bbox to depth map coordinates
360
- const x1 = Math.max(0, Math.floor(b.x * dw));
361
- const y1 = Math.max(0, Math.floor(b.y * dh));
362
- const x2 = Math.min(dw - 1, Math.floor((b.x + b.w) * dw));
363
- const y2 = Math.min(dh - 1, Math.floor((b.y + b.h) * dh));
364
-
365
- let sum = 0;
366
- let count = 0;
367
- let localMin = Infinity;
368
- let localMax = -Infinity;
369
-
370
- for (let py = y1; py <= y2; py++) {
371
- for (let px = x1; px <= x2; px++) {
372
- const val = dd[py * dw + px];
373
- if (isFinite(val)) {
374
- sum += val;
375
- count++;
376
- if (val < localMin) localMin = val;
377
- if (val > localMax) localMax = val;
378
- }
379
- }
380
- }
381
-
382
- if (count === 0) return;
383
-
384
- const avgDepth = sum / count;
385
-
386
- // Draw stats label below the bbox
387
- const bx = b.x * canvasW;
388
- const by = (b.y + b.h) * canvasH;
389
-
390
- ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
391
- ctx.fillRect(bx, by + 4, 160, 18);
392
-
393
- ctx.fillStyle = "rgba(253, 231, 37, 0.9)"; // viridis yellow for contrast
394
- ctx.font = "bold 11px monospace";
395
- ctx.textAlign = "left";
396
- ctx.fillText(`Depth: ${avgDepth.toFixed(1)}m (${localMin.toFixed(1)}-${localMax.toFixed(1)})`, bx + 4, by + 16);
397
- };
398
-
399
- /**
400
- * Draw a highlighted bounding box for the selected track.
401
- */
402
- APP.ui.inspectionRenders._drawBBoxHighlight = function () {
403
- // No-op — inspection shows the cropped object only, no bbox overlay
404
- };
405
-
406
- /**
407
- * Viridis-like colormap: t in [0,1] -> [R, G, B] in [0,255].
408
- * Simplified 5-stop linear interpolation.
409
- */
410
- APP.ui.inspectionRenders._viridis = function (t) {
411
- const stops = [
412
- [0.0, 68, 1, 84],
413
- [0.25, 59, 82, 139],
414
- [0.5, 33, 145, 140],
415
- [0.75, 94, 201, 98],
416
- [1.0, 253, 231, 37]
417
- ];
418
- return APP.ui.inspectionRenders._interpolateColormap(t, stops);
419
- };
420
-
421
- /**
422
- * Linearly interpolate a colormap defined by stops.
423
- * @param {number} t — value in [0,1]
424
- * @param {Array} stops — [[t, r, g, b], ...] sorted by t
425
- * @returns {number[]} [r, g, b]
426
- */
427
- APP.ui.inspectionRenders._interpolateColormap = function (t, stops) {
428
- if (t <= stops[0][0]) return [stops[0][1], stops[0][2], stops[0][3]];
429
- if (t >= stops[stops.length - 1][0]) {
430
- const s = stops[stops.length - 1];
431
- return [s[1], s[2], s[3]];
432
- }
433
-
434
- for (let i = 0; i < stops.length - 1; i++) {
435
- if (t >= stops[i][0] && t <= stops[i + 1][0]) {
436
- const f = (t - stops[i][0]) / (stops[i + 1][0] - stops[i][0]);
437
- return [
438
- Math.round(stops[i][1] + f * (stops[i + 1][1] - stops[i][1])),
439
- Math.round(stops[i][2] + f * (stops[i + 1][2] - stops[i][2])),
440
- Math.round(stops[i][3] + f * (stops[i + 1][3] - stops[i][3]))
441
- ];
442
- }
443
- }
444
-
445
- return [0, 0, 0];
446
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/inspection.js DELETED
@@ -1,520 +0,0 @@
1
- // Inspection Panel Controller — quad view: seg, edge, depth, 3D simultaneously
2
- APP.ui.inspection = {};
3
-
4
- /**
5
- * Initialize: wire close button, quad expand/collapse, Escape key.
6
- * Called once from main.js init().
7
- */
8
- APP.ui.inspection.init = function () {
9
- const { $ } = APP.core.utils;
10
-
11
- // Close button
12
- const closeBtn = $("#btnCloseInspection");
13
- if (closeBtn) {
14
- closeBtn.addEventListener("click", () => APP.ui.inspection.close());
15
- }
16
-
17
- // Quad expand/collapse (event delegation)
18
- const quad = $("#inspectionQuad");
19
- if (quad) {
20
- quad.addEventListener("click", (e) => {
21
- const quadrant = e.target.closest(".inspection-quadrant");
22
- if (!quadrant) return;
23
- const mode = quadrant.getAttribute("data-mode");
24
- APP.ui.inspection._toggleExpand(mode);
25
- });
26
- }
27
-
28
- // Escape to collapse
29
- document.addEventListener("keydown", (e) => {
30
- if (e.key === "Escape" && APP.core.state.inspection.expandedQuadrant) {
31
- APP.ui.inspection._collapseExpanded();
32
- }
33
- });
34
- };
35
-
36
- /**
37
- * Open the inspection panel for a specific track.
38
- * @param {string} trackId
39
- */
40
- APP.ui.inspection.open = function (trackId) {
41
- const { state } = APP.core;
42
- const { $ } = APP.core.utils;
43
-
44
- if (!trackId) return;
45
-
46
- const panel = $("#inspectionPanel");
47
- if (!panel) return;
48
-
49
- // Determine current frame index
50
- const video = $("#videoEngage");
51
- const fps = state.hf.fps || 30;
52
- const maxFrame = state.hf.totalFrames ? state.hf.totalFrames - 1 : Infinity;
53
- let frameIdx = 0;
54
- if (video && isFinite(video.currentTime)) {
55
- frameIdx = Math.min(Math.floor(video.currentTime * fps), maxFrame);
56
- }
57
- if (!isFinite(frameIdx) || frameIdx < 0) frameIdx = 0;
58
-
59
- // Collapse any expanded quadrant
60
- APP.ui.inspection._collapseExpanded();
61
-
62
- // Update state
63
- state.inspection.trackId = trackId;
64
- state.inspection.frameIdx = frameIdx;
65
- state.inspection.visible = true;
66
- state.inspection.error = null;
67
-
68
- // Clear caches
69
- APP.ui.inspection._clearCaches();
70
-
71
- // Show panel
72
- panel.style.display = "flex";
73
-
74
- // Hide empty state
75
- const empty = $("#inspectionEmpty");
76
- if (empty) empty.style.display = "none";
77
-
78
- // Update header
79
- const track = (state.detections || []).find(d => d.id === trackId);
80
- APP.ui.inspection._updateHeader(track, frameIdx);
81
-
82
- // Load all quadrants
83
- APP.ui.inspection._loadAllQuadrants();
84
-
85
- // Load explainability graph
86
- const expJobId = state.hf.asyncJobId || state.hf.completedJobId;
87
- if (expJobId && APP.ui.explainability) {
88
- APP.ui.explainability.load(expJobId, trackId);
89
- }
90
- };
91
-
92
- /**
93
- * Close the inspection panel.
94
- */
95
- APP.ui.inspection.close = function () {
96
- const { state } = APP.core;
97
- const { $ } = APP.core.utils;
98
-
99
- state.inspection.visible = false;
100
- state.inspection.trackId = null;
101
- state.inspection.loading = false;
102
- state.inspection.error = null;
103
-
104
- APP.ui.inspection._collapseExpanded();
105
- APP.ui.inspection._clearCaches();
106
-
107
- // Clear explainability graph (but NOT the cache — it's track-scoped)
108
- if (APP.ui.explainability) APP.ui.explainability.clear();
109
-
110
- const panel = $("#inspectionPanel");
111
- if (panel) panel.style.display = "none";
112
-
113
- // Clean up 3D scene
114
- if (APP.ui.inspection3d && APP.ui.inspection3d.dispose) {
115
- APP.ui.inspection3d.dispose();
116
- }
117
- };
118
-
119
- /**
120
- * Refresh inspection for the current video time (called when user seeks).
121
- */
122
- APP.ui.inspection.refreshFrame = function () {
123
- const { state } = APP.core;
124
- if (!state.inspection.visible || !state.inspection.trackId) return;
125
-
126
- const { $ } = APP.core.utils;
127
- const video = $("#videoEngage");
128
- const fps = state.hf.fps || 30;
129
- const maxFrame = state.hf.totalFrames ? state.hf.totalFrames - 1 : Infinity;
130
- let frameIdx = 0;
131
- if (video && isFinite(video.currentTime)) {
132
- frameIdx = Math.min(Math.floor(video.currentTime * fps), maxFrame);
133
- }
134
- if (!isFinite(frameIdx) || frameIdx < 0) frameIdx = 0;
135
-
136
- if (frameIdx === state.inspection.frameIdx) return;
137
-
138
- state.inspection.frameIdx = frameIdx;
139
- APP.ui.inspection._clearCaches();
140
-
141
- // Update header frame counter
142
- const track = (state.detections || []).find(d => d.id === state.inspection.trackId);
143
- APP.ui.inspection._updateHeader(track, frameIdx);
144
-
145
- APP.ui.inspection._loadAllQuadrants();
146
- };
147
-
148
- /**
149
- * Update the inspection header bar.
150
- */
151
- APP.ui.inspection._updateHeader = function (track, frameIdx) {
152
- const { $ } = APP.core.utils;
153
- const { state } = APP.core;
154
- const totalFrames = state.hf.totalFrames || "--";
155
-
156
- const nameEl = $("#inspectionObjName");
157
- const confEl = $("#inspectionConf");
158
- const statusEl = $("#inspectionStatus");
159
- const frameEl = $("#inspectionFrame");
160
-
161
- if (nameEl) nameEl.textContent = (track && track.label || "UNKNOWN").toUpperCase();
162
- if (confEl) confEl.textContent = track ? (track.score || 0).toFixed(2) : "--";
163
- if (frameEl) frameEl.textContent = `FRM ${frameIdx} / ${totalFrames}`;
164
-
165
- if (statusEl) {
166
- statusEl.className = "inspection-status";
167
- if (track && track.satisfies === true) {
168
- statusEl.textContent = "MISSION MATCH";
169
- statusEl.classList.add("match");
170
- } else if (track && track.satisfies === false) {
171
- statusEl.textContent = "NO MATCH";
172
- statusEl.classList.add("no-match");
173
- } else {
174
- statusEl.textContent = "PENDING";
175
- statusEl.classList.add("pending");
176
- }
177
- }
178
- };
179
-
180
- /**
181
- * Clear all cached visualization data.
182
- */
183
- APP.ui.inspection._clearCaches = function () {
184
- const { state } = APP.core;
185
- const cache = state.inspection.cache;
186
-
187
- if (state.inspection._frameImg && state.inspection._frameImg._blobUrl) {
188
- URL.revokeObjectURL(state.inspection._frameImg._blobUrl);
189
- }
190
-
191
- state.inspection.frameImageUrl = null;
192
- state.inspection._frameImg = null;
193
- cache.seg = null;
194
- cache.edge = null;
195
- cache.depth = null;
196
- cache.pointcloud = null;
197
- };
198
-
199
- /**
200
- * Load and render all four quadrants in parallel.
201
- */
202
- APP.ui.inspection._loadAllQuadrants = async function () {
203
- const { state } = APP.core;
204
- const { $ } = APP.core.utils;
205
- const api = APP.api.inspection;
206
- const renders = APP.ui.inspectionRenders;
207
- const { trackId, frameIdx, cache } = state.inspection;
208
-
209
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
210
- if (!jobId || !trackId || frameIdx == null) {
211
- const msg = !jobId ? "No active job — run detection first"
212
- : !trackId ? "No track selected"
213
- : "Frame index unavailable";
214
- APP.ui.inspection._showQuadError("seg", msg);
215
- APP.ui.inspection._showQuadError("edge", msg);
216
- APP.ui.inspection._showQuadError("depth", msg);
217
- APP.ui.inspection._showQuadError("3d", msg);
218
- return;
219
- }
220
-
221
- const track = (state.detections || []).find(d => d.id === trackId);
222
- if (!track || !track.bbox) {
223
- const msg = "Track not found in current frame";
224
- APP.ui.inspection._showQuadError("seg", msg);
225
- APP.ui.inspection._showQuadError("edge", msg);
226
- APP.ui.inspection._showQuadError("depth", msg);
227
- APP.ui.inspection._showQuadError("3d", msg);
228
- return;
229
- }
230
-
231
- try {
232
- // Step 1: Fetch shared frame image
233
- if (!state.inspection._frameImg) {
234
- const frameImg = await api.fetchFrame(jobId, frameIdx, trackId, 0.20);
235
- state.inspection._frameImg = frameImg;
236
- state.inspection.frameImageUrl = frameImg.src;
237
- }
238
- } catch (err) {
239
- APP.ui.inspection._showQuadError("seg", "Frame load failed");
240
- APP.ui.inspection._showQuadError("edge", "Frame load failed");
241
- APP.ui.inspection._showQuadError("depth", "Frame load failed");
242
- APP.ui.inspection._showQuadError("3d", "Frame load failed");
243
- return;
244
- }
245
-
246
- const frameImg = state.inspection._frameImg;
247
-
248
- // Step 2: Fire seg, edge, depth in parallel; 3D is on-demand
249
- const segPromise = (async () => {
250
- APP.ui.inspection._showQuadLoading("seg", true);
251
- try {
252
- if (!cache.seg) cache.seg = await api.fetchMask(jobId, frameIdx, trackId);
253
- const canvas = $("#quadCanvasSeg");
254
- if (canvas && renders) renders._renderSeg(canvas, frameImg, cache.seg, track);
255
- APP.ui.inspection._updateQuadMetric("seg", cache.seg);
256
- } catch (e) {
257
- APP.ui.inspection._showQuadError("seg", e.message);
258
- }
259
- APP.ui.inspection._showQuadLoading("seg", false);
260
- })();
261
-
262
- const edgePromise = (async () => {
263
- try {
264
- if (!cache.edge && frameImg && renders) {
265
- cache.edge = renders.computeEdge(frameImg);
266
- }
267
- const canvas = $("#quadCanvasEdge");
268
- if (canvas && renders) renders._renderEdge(canvas, frameImg, cache.edge, track);
269
- } catch (e) {
270
- console.error("Edge render error:", e);
271
- }
272
- })();
273
-
274
- const depthPromise = (async () => {
275
- APP.ui.inspection._showQuadLoading("depth", true);
276
- try {
277
- if (!cache.depth) cache.depth = await api.fetchDepth(jobId, frameIdx, trackId);
278
- const canvas = $("#quadCanvasDepth");
279
- if (canvas && renders) renders._renderDepth(canvas, frameImg, cache.depth, track);
280
- APP.ui.inspection._updateQuadMetric("depth", cache.depth);
281
- } catch (e) {
282
- APP.ui.inspection._showQuadError("depth", e.message);
283
- }
284
- APP.ui.inspection._showQuadLoading("depth", false);
285
- })();
286
-
287
- await Promise.allSettled([segPromise, edgePromise, depthPromise]);
288
-
289
- // Step 3: Show 3D on-demand prompt (no auto-loading)
290
- APP.ui.inspection._show3dPrompt();
291
-
292
- // Step 4: Update bottom metrics
293
- APP.ui.inspection._updateBottomMetrics(track, cache);
294
- };
295
-
296
- /**
297
- * Show on-demand 3D generation prompt in the 3D quadrant.
298
- */
299
- APP.ui.inspection._show3dPrompt = function () {
300
- const container = document.getElementById("quad3dContainer");
301
- if (!container) return;
302
-
303
- // Clean up existing 3D scene
304
- if (APP.ui.inspection3d && APP.ui.inspection3d.dispose) {
305
- APP.ui.inspection3d.dispose();
306
- }
307
-
308
- // Clear container
309
- container.innerHTML = "";
310
-
311
- const prompt = document.createElement("div");
312
- prompt.className = "quad-3d-prompt";
313
- prompt.innerHTML = `
314
- <div class="quad-3d-prompt-icon">
315
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
316
- <path d="M12 2L2 7l10 5 10-5-10-5z"/>
317
- <path d="M2 17l10 5 10-5"/>
318
- <path d="M2 12l10 5 10-5"/>
319
- </svg>
320
- </div>
321
- <div class="quad-3d-prompt-label">3D MODEL</div>
322
- <button class="quad-3d-btn" data-mode="depth">COMPUTE 3D</button>
323
- <button class="quad-3d-btn quad-3d-btn-gen" data-mode="generative">GENERATE AI 3D</button>
324
- <div class="quad-3d-prompt-hint">AI 3D uses Tripo API (external)</div>
325
- `;
326
-
327
- // Wire button handlers
328
- prompt.querySelector('[data-mode="depth"]').addEventListener("click", (e) => {
329
- e.stopPropagation();
330
- APP.ui.inspection._trigger3d(false);
331
- });
332
- prompt.querySelector('[data-mode="generative"]').addEventListener("click", (e) => {
333
- e.stopPropagation();
334
- APP.ui.inspection._trigger3d(true);
335
- });
336
-
337
- container.appendChild(prompt);
338
- };
339
-
340
- /**
341
- * Trigger 3D model generation (on-demand).
342
- * @param {boolean} generative — if true, use Tripo3D (API cost)
343
- */
344
- APP.ui.inspection._trigger3d = async function (generative) {
345
- const { state } = APP.core;
346
- const api = APP.api.inspection;
347
- const { trackId, frameIdx, cache } = state.inspection;
348
- const jobId = state.hf.asyncJobId || state.hf.completedJobId;
349
-
350
- if (!jobId || !trackId || frameIdx == null) return;
351
-
352
- // Clear prompt and show loading with status
353
- const container = document.getElementById("quad3dContainer");
354
- if (container) container.innerHTML = "";
355
-
356
- const statusEl = document.createElement("div");
357
- statusEl.className = "quad-3d-status";
358
- statusEl.innerHTML = generative
359
- ? '<div class="inspection-spinner"></div><div class="quad-3d-status-text">Uploading to Tripo3D...</div>'
360
- : '<div class="inspection-spinner"></div><div class="quad-3d-status-text">Computing depth mesh...</div>';
361
- if (container) container.appendChild(statusEl);
362
-
363
- APP.ui.inspection._showQuadLoading("3d", true);
364
- APP.ui.inspection._showQuadError("3d", "");
365
-
366
- // Update metric to show status
367
- const metricEl = document.getElementById("quadMetric3d");
368
- if (metricEl) metricEl.textContent = generative ? "TRIPO3D GENERATING..." : "COMPUTING...";
369
-
370
- try {
371
- cache.pointcloud = await api.fetchPointCloud(jobId, frameIdx, trackId, generative);
372
- if (container) container.innerHTML = "";
373
-
374
- if (cache.pointcloud && APP.ui.inspection3d) {
375
- if (cache.pointcloud.type === "glb") {
376
- await APP.ui.inspection3d.renderGLB(cache.pointcloud.buffer);
377
- } else {
378
- APP.ui.inspection3d.render(cache.pointcloud);
379
- }
380
- }
381
- APP.ui.inspection._updateQuadMetric("3d", cache.pointcloud);
382
- } catch (e) {
383
- if (container) container.innerHTML = "";
384
- APP.ui.inspection._showQuadError("3d", e.message);
385
- if (metricEl) metricEl.textContent = "FAILED";
386
- }
387
- APP.ui.inspection._showQuadLoading("3d", false);
388
- };
389
-
390
- /**
391
- * Toggle per-quadrant loading spinner.
392
- */
393
- APP.ui.inspection._showQuadLoading = function (mode, show) {
394
- const modeToId = { seg: "quadSeg", edge: "quadEdge", depth: "quadDepth", "3d": "quad3d" };
395
- const el = document.getElementById(modeToId[mode]);
396
- if (!el) return;
397
- const loader = el.querySelector(".quad-loading");
398
- if (loader) loader.style.display = show ? "flex" : "none";
399
- };
400
-
401
- /**
402
- * Show per-quadrant error message.
403
- */
404
- APP.ui.inspection._showQuadError = function (mode, msg) {
405
- const modeToId = { seg: "quadSeg", edge: "quadEdge", depth: "quadDepth", "3d": "quad3d" };
406
- const el = document.getElementById(modeToId[mode]);
407
- if (!el) return;
408
- const errEl = el.querySelector(".quad-error");
409
- if (errEl) {
410
- errEl.textContent = msg || "";
411
- errEl.style.display = msg ? "flex" : "none";
412
- }
413
- };
414
-
415
- /**
416
- * Update per-quadrant metric overlay text.
417
- */
418
- APP.ui.inspection._updateQuadMetric = function (mode, data) {
419
- const { $ } = APP.core.utils;
420
- if (!data) return;
421
-
422
- if (mode === "seg") {
423
- // Count mask pixels from RLE or mask image
424
- let area = 0;
425
- if (data.rle && data.rle.counts) {
426
- let val = 0;
427
- for (const count of data.rle.counts) {
428
- if (val) area += count;
429
- val = 1 - val;
430
- }
431
- }
432
- const el = $("#quadMetricSeg");
433
- if (el) el.textContent = area > 0 ? `AREA ${area.toLocaleString()} px` : "";
434
- } else if (mode === "depth") {
435
- const el = $("#quadMetricDepth");
436
- if (el && data.min != null && data.max != null) {
437
- const avg = ((data.min + data.max) / 2).toFixed(1);
438
- el.textContent = `${avg}m`;
439
- }
440
- } else if (mode === "3d") {
441
- const el = $("#quadMetric3d");
442
- if (el) {
443
- if (data.type === "glb") {
444
- el.textContent = "GENERATIVE 3D";
445
- } else {
446
- const n = data.numVertices || (data.positions ? data.positions.length / 3 : 0);
447
- const label = data.renderMode === "mesh" ? "verts" : "pts";
448
- el.textContent = `${n.toLocaleString()} ${label}`;
449
- }
450
- }
451
- }
452
- };
453
-
454
- /**
455
- * Update the bottom metrics strip.
456
- */
457
- APP.ui.inspection._updateBottomMetrics = function (track, cache) {
458
- const { $ } = APP.core.utils;
459
- const el = $("#inspectionMetrics");
460
- if (!el || !track) return;
461
-
462
- const speed = track.speed_kph != null ? `${Math.round(track.speed_kph)} kph` : "N/A";
463
- const depth = track.depth_est_m != null ? `${track.depth_est_m.toFixed(1)}m` : "N/A";
464
-
465
- let area = "N/A";
466
- if (cache.seg && cache.seg.rle && cache.seg.rle.counts) {
467
- let px = 0, val = 0;
468
- for (const count of cache.seg.rle.counts) {
469
- if (val) px += count;
470
- val = 1 - val;
471
- }
472
- if (px > 0) area = `${px.toLocaleString()} px`;
473
- }
474
-
475
- const frames = track.frameCount || "--";
476
-
477
- el.innerHTML =
478
- `VEL <span>${speed}</span>` +
479
- `DEPTH <span>${depth}</span>` +
480
- `AREA <span>${area}</span>` +
481
- `TRACKED <span>${frames} frm</span>`;
482
- };
483
-
484
- /**
485
- * Toggle expand/collapse of a quadrant.
486
- */
487
- APP.ui.inspection._toggleExpand = function (mode) {
488
- const { state } = APP.core;
489
- if (state.inspection.expandedQuadrant === mode) {
490
- APP.ui.inspection._collapseExpanded();
491
- } else {
492
- APP.ui.inspection._collapseExpanded();
493
- const modeToId = { seg: "quadSeg", edge: "quadEdge", depth: "quadDepth", "3d": "quad3d" };
494
- const el = document.getElementById(modeToId[mode]);
495
- if (el) {
496
- el.classList.add("expanded");
497
- state.inspection.expandedQuadrant = mode;
498
- // Trigger resize for 3D renderer
499
- if (mode === "3d") {
500
- setTimeout(() => window.dispatchEvent(new Event("resize")), 50);
501
- }
502
- }
503
- }
504
- };
505
-
506
- /**
507
- * Collapse any currently expanded quadrant.
508
- */
509
- APP.ui.inspection._collapseExpanded = function () {
510
- const { state } = APP.core;
511
- const was3d = state.inspection.expandedQuadrant === "3d";
512
-
513
- const expanded = document.querySelector(".inspection-quadrant.expanded");
514
- if (expanded) expanded.classList.remove("expanded");
515
- state.inspection.expandedQuadrant = null;
516
-
517
- if (was3d) {
518
- setTimeout(() => window.dispatchEvent(new Event("resize")), 50);
519
- }
520
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/logging.js DELETED
@@ -1,55 +0,0 @@
1
- APP.ui.logging = {};
2
-
3
- APP.ui.logging.log = function (msg, type = "i") {
4
- const { $ } = APP.core.utils;
5
- const consoleHook = $("#sysLog"); // Fixed ID: sysLog from html
6
- if (!consoleHook) return;
7
- const div = document.createElement("div");
8
- div.className = "log-line";
9
- const ts = new Date().toISOString().split("T")[1].slice(0, 8);
10
- let color = "#94a3b8"; // dim
11
- if (type === "g") color = "var(--success)";
12
- if (type === "w") color = "var(--warning)";
13
- if (type === "e") color = "var(--danger)";
14
- if (type === "t") color = "var(--accent2)";
15
-
16
- div.innerHTML = `<span class="ts">[${ts}]</span> <span style="color:${color}">${msg}</span>`;
17
- consoleHook.appendChild(div);
18
- consoleHook.scrollTop = consoleHook.scrollHeight;
19
-
20
- // Fire toast for error messages
21
- if (type === "e" && APP.ui.animations && APP.ui.animations.toast) {
22
- APP.ui.animations.toast(msg, "error", 5000);
23
- }
24
- };
25
-
26
- APP.ui.logging.setStatus = function (level, text) {
27
- const { $ } = APP.core.utils;
28
- const sysDot = $("#sys-dot");
29
- const sysStatus = $("#sys-status");
30
- if (!sysDot || !sysStatus) return;
31
- let color = "var(--text3)";
32
- if (level === "good") color = "var(--success)";
33
- if (level === "warn") color = "var(--warning)";
34
- if (level === "bad") color = "var(--danger)";
35
-
36
- // Update dot CSS class for state-aware styling
37
- sysDot.classList.remove("warn", "bad");
38
- if (level === "warn") sysDot.classList.add("warn");
39
- if (level === "bad") sysDot.classList.add("bad");
40
-
41
- sysDot.style.background = color;
42
- sysDot.style.boxShadow = `0 0 8px ${color}`;
43
- sysStatus.textContent = text;
44
- sysStatus.style.color = color === "var(--text3)" ? "var(--text)" : color;
45
- };
46
-
47
- APP.ui.logging.setHfStatus = function (msg) {
48
- const { $ } = APP.core.utils;
49
- const el = $("#hfBackendStatus");
50
- if (el) el.textContent = `Backend: ${msg}`;
51
-
52
- if (msg && (msg.toLowerCase().includes("error") || msg.toLowerCase().includes("failed"))) {
53
- APP.ui.logging.log(msg, "e");
54
- }
55
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/js/ui/overlays.js DELETED
@@ -1,187 +0,0 @@
1
- // SVG-based overlay for bounding box rendering and click-to-select
2
- APP.ui.overlays = {};
3
-
4
- const SVG_NS = "http://www.w3.org/2000/svg";
5
-
6
- /**
7
- * Get current tracks from state (detections preferred, then tracker.tracks).
8
- */
9
- APP.ui.overlays._getTracks = function () {
10
- const { state } = APP.core;
11
- if (state.detections && state.detections.length > 0) return state.detections;
12
- if (state.tracker.tracks && state.tracker.tracks.length > 0) return state.tracker.tracks;
13
- return [];
14
- };
15
-
16
- /**
17
- * Ensure the SVG element exists inside the viewbox, create if missing.
18
- * Uses viewBox="0 0 1 1" so all coordinates are normalized 0-1.
19
- */
20
- APP.ui.overlays._ensureSVG = function () {
21
- const { $ } = APP.core.utils;
22
- let svg = $("#engageOverlay");
23
- if (svg) return svg;
24
-
25
- const viewbox = $(".viewbox");
26
- if (!viewbox) return null;
27
-
28
- svg = document.createElementNS(SVG_NS, "svg");
29
- svg.id = "engageOverlay";
30
- svg.setAttribute("viewBox", "0 0 1 1");
31
- svg.setAttribute("preserveAspectRatio", "none");
32
-
33
- // Insert after video element
34
- const video = $("#videoEngage");
35
- if (video && video.nextSibling) {
36
- viewbox.insertBefore(svg, video.nextSibling);
37
- } else {
38
- viewbox.appendChild(svg);
39
- }
40
-
41
- return svg;
42
- };
43
-
44
- /**
45
- * Sync SVG overlay position/size to match the video's actual rendered area
46
- * (accounting for object-fit: contain letterboxing).
47
- */
48
- APP.ui.overlays._syncToVideo = function (svg) {
49
- const { $ } = APP.core.utils;
50
- const video = $("#videoEngage");
51
- if (!video || !video.videoWidth || !video.videoHeight) return;
52
-
53
- const container = svg.parentElement;
54
- if (!container) return;
55
-
56
- const cw = container.clientWidth;
57
- const ch = container.clientHeight;
58
- const vw = video.videoWidth;
59
- const vh = video.videoHeight;
60
-
61
- // Calculate rendered video area within container (object-fit: contain)
62
- const containerRatio = cw / ch;
63
- const videoRatio = vw / vh;
64
-
65
- let renderW, renderH, offsetX, offsetY;
66
- if (videoRatio > containerRatio) {
67
- // Video wider than container — letterbox top/bottom
68
- renderW = cw;
69
- renderH = cw / videoRatio;
70
- offsetX = 0;
71
- offsetY = (ch - renderH) / 2;
72
- } else {
73
- // Video taller than container — letterbox left/right
74
- renderH = ch;
75
- renderW = ch * videoRatio;
76
- offsetX = (cw - renderW) / 2;
77
- offsetY = 0;
78
- }
79
-
80
- svg.style.left = offsetX + "px";
81
- svg.style.top = offsetY + "px";
82
- svg.style.width = renderW + "px";
83
- svg.style.height = renderH + "px";
84
- };
85
-
86
- /**
87
- * Main render: draws all track boxes with mission-status colors,
88
- * and highlights the selected track.
89
- */
90
- APP.ui.overlays.render = function () {
91
- const { state } = APP.core;
92
- const svg = APP.ui.overlays._ensureSVG();
93
- if (!svg) return;
94
-
95
- // Sync SVG position to video's rendered area
96
- APP.ui.overlays._syncToVideo(svg);
97
-
98
- // Clear previous content
99
- svg.innerHTML = "";
100
-
101
- // In segmentation mode, masks are already rendered in the video by the backend —
102
- // skip drawing bounding box overlays to avoid clutter.
103
- if (state.hf.mode === "segmentation") return;
104
-
105
- const tracks = APP.ui.overlays._getTracks();
106
- const selIds = state.selectedIds || [];
107
- const inspectedId = state.tracker.selectedTrackId;
108
-
109
- if (!tracks.length) return;
110
-
111
- for (const t of tracks) {
112
- if (!t.bbox) continue;
113
- const b = t.bbox;
114
- const isSelected = selIds.includes(t.id);
115
- const isInspected = t.id === inspectedId;
116
-
117
- // Skip tracks with invalid bbox
118
- if (!isFinite(b.x) || !isFinite(b.y) || !isFinite(b.w) || !isFinite(b.h)) continue;
119
-
120
- const group = document.createElementNS(SVG_NS, "g");
121
- group.setAttribute("data-track-id", t.id);
122
- group.classList.add("track-box");
123
- if (isSelected) group.classList.add("selected");
124
- if (isInspected) group.classList.add("inspected");
125
-
126
- // Mission-status border (green/red) or default neutral
127
- let strokeColor = "rgba(255,255,255,0.3)";
128
- if (t.satisfies === true) {
129
- strokeColor = "rgba(40, 167, 69, 0.8)"; // green
130
- } else if (t.satisfies === false) {
131
- strokeColor = "rgba(220, 53, 69, 0.8)"; // red
132
- }
133
-
134
- // Main bbox rect
135
- const rect = document.createElementNS(SVG_NS, "rect");
136
- rect.setAttribute("x", b.x);
137
- rect.setAttribute("y", b.y);
138
- rect.setAttribute("width", b.w);
139
- rect.setAttribute("height", b.h);
140
- rect.setAttribute("fill", isSelected ? "rgba(59, 130, 246, 0.12)" : "none");
141
- rect.setAttribute("stroke", isSelected ? "rgba(59, 130, 246, 0.7)" : strokeColor);
142
- rect.setAttribute("stroke-width", isInspected ? "0.004" : isSelected ? "0.003" : "0.002");
143
- rect.setAttribute("vector-effect", "non-scaling-stroke");
144
- group.appendChild(rect);
145
-
146
- // Selected glow border
147
- if (isSelected) {
148
- const glow = document.createElementNS(SVG_NS, "rect");
149
- glow.setAttribute("x", b.x);
150
- glow.setAttribute("y", b.y);
151
- glow.setAttribute("width", b.w);
152
- glow.setAttribute("height", b.h);
153
- glow.setAttribute("fill", "none");
154
- glow.setAttribute("stroke", isInspected ? "rgba(96, 165, 250, 0.6)" : "rgba(96, 165, 250, 0.25)");
155
- glow.setAttribute("stroke-width", isInspected ? "2" : "1");
156
- glow.setAttribute("vector-effect", "non-scaling-stroke");
157
- group.appendChild(glow);
158
- }
159
-
160
- // No label tag — backend-rendered labels are sufficient
161
-
162
- // Click handler for this track group
163
- group.addEventListener("click", (e) => {
164
- e.stopPropagation();
165
- document.dispatchEvent(new CustomEvent("track-selected", { detail: { id: t.id } }));
166
- });
167
-
168
- svg.appendChild(group);
169
- }
170
-
171
- };
172
-
173
- /**
174
- * Initialize the SVG overlay (creates element if needed).
175
- * Attaches the empty-area click handler once.
176
- */
177
- APP.ui.overlays.init = function () {
178
- const svg = APP.ui.overlays._ensureSVG();
179
- if (svg && !svg._deselectBound) {
180
- svg.addEventListener("click", (e) => {
181
- if (e.target === svg) {
182
- document.dispatchEvent(new CustomEvent("track-selected", { detail: { id: null } }));
183
- }
184
- });
185
- svg._deselectBound = true;
186
- }
187
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/style.css DELETED
@@ -1,2028 +0,0 @@
1
- /* =========================================
2
- ISR Analytics — Investor Demo
3
- Floating Glass HUD Design System
4
- ========================================= */
5
-
6
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
7
-
8
- :root {
9
- /* --- Surface --- */
10
- --bg: #06080f;
11
- --surface: rgba(12, 17, 30, .72);
12
- --surface2: rgba(16, 22, 38, .65);
13
- --surface3: rgba(22, 30, 50, .60);
14
- --glass: rgba(12, 17, 30, .55);
15
-
16
- /* --- Border --- */
17
- --border: rgba(255, 255, 255, .06);
18
- --border2: rgba(255, 255, 255, .09);
19
- --border3: rgba(255, 255, 255, .14);
20
-
21
- /* --- Text --- */
22
- --text: rgba(255, 255, 255, .92);
23
- --text2: rgba(255, 255, 255, .58);
24
- --text3: rgba(255, 255, 255, .34);
25
-
26
- /* --- Semantic --- */
27
- --success: #34d399;
28
- --warning: #fbbf24;
29
- --danger: #f87171;
30
-
31
- /* --- Accent --- */
32
- --accent: #3b82f6;
33
- --accent2: #60a5fa;
34
- --accent-glow: rgba(59, 130, 246, .15);
35
-
36
- /* --- Effects --- */
37
- --blur: blur(24px);
38
- --blur-heavy: blur(40px);
39
- --shadow-sm: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
40
- --shadow-md: 0 4px 20px rgba(0,0,0,.35);
41
- --shadow-lg: 0 12px 48px rgba(0,0,0,.5);
42
- --shadow-glow: 0 0 40px rgba(59, 130, 246, .08);
43
-
44
- --radius-sm: 8px;
45
- --radius-md: 12px;
46
- --radius-lg: 16px;
47
- --radius-xl: 20px;
48
-
49
- /* --- Typography --- */
50
- --font: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
51
- --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
52
- --sans: var(--font);
53
- }
54
-
55
- /* ---- Reset ---- */
56
- * { box-sizing: border-box; margin: 0; padding: 0; }
57
- html, body { height: 100%; }
58
-
59
- body {
60
- background: var(--bg);
61
- color: var(--text);
62
- font-family: var(--font);
63
- font-size: 13px;
64
- line-height: 1.5;
65
- overflow: hidden;
66
- -webkit-font-smoothing: antialiased;
67
- -moz-osx-font-smoothing: grayscale;
68
- }
69
-
70
- /* =========================================
71
- Ambient Background
72
- ========================================= */
73
-
74
- #ambientBg {
75
- position: fixed;
76
- inset: 0;
77
- z-index: 0;
78
- pointer-events: none;
79
- overflow: hidden;
80
- }
81
-
82
- #ambientBg .orb {
83
- position: absolute;
84
- border-radius: 50%;
85
- filter: blur(100px);
86
- opacity: 0.4;
87
- will-change: transform;
88
- }
89
-
90
- #ambientBg .orb-1 {
91
- width: 600px; height: 600px;
92
- background: radial-gradient(circle, rgba(59, 130, 246, .18), transparent 70%);
93
- top: -10%; left: -5%;
94
- animation: orbFloat1 25s ease-in-out infinite;
95
- }
96
-
97
- #ambientBg .orb-2 {
98
- width: 500px; height: 500px;
99
- background: radial-gradient(circle, rgba(99, 102, 241, .12), transparent 70%);
100
- bottom: -15%; right: -5%;
101
- animation: orbFloat2 30s ease-in-out infinite;
102
- }
103
-
104
- #ambientBg .orb-3 {
105
- width: 400px; height: 400px;
106
- background: radial-gradient(circle, rgba(14, 165, 233, .10), transparent 70%);
107
- top: 40%; left: 50%;
108
- animation: orbFloat3 20s ease-in-out infinite;
109
- }
110
-
111
- @keyframes orbFloat1 {
112
- 0%, 100% { transform: translate(0, 0) scale(1); }
113
- 33% { transform: translate(80px, 40px) scale(1.1); }
114
- 66% { transform: translate(-40px, 80px) scale(0.95); }
115
- }
116
- @keyframes orbFloat2 {
117
- 0%, 100% { transform: translate(0, 0) scale(1); }
118
- 33% { transform: translate(-60px, -30px) scale(1.05); }
119
- 66% { transform: translate(40px, -60px) scale(0.9); }
120
- }
121
- @keyframes orbFloat3 {
122
- 0%, 100% { transform: translate(0, 0) scale(1); }
123
- 50% { transform: translate(60px, -40px) scale(1.15); }
124
- }
125
-
126
- /* State-aware ambient: processing = pulse */
127
- body.state-processing #ambientBg .orb-1 {
128
- animation: orbPulse 3s ease-in-out infinite;
129
- }
130
-
131
- @keyframes orbPulse {
132
- 0%, 100% { opacity: 0.3; transform: scale(1); }
133
- 50% { opacity: 0.6; transform: scale(1.15); }
134
- }
135
-
136
- /* =========================================
137
- Layout Shell
138
- ========================================= */
139
-
140
- #app {
141
- position: relative;
142
- z-index: 1;
143
- height: 100%;
144
- display: flex;
145
- flex-direction: column;
146
- }
147
-
148
- /* ---- Header ---- */
149
- header {
150
- display: flex;
151
- align-items: center;
152
- justify-content: space-between;
153
- padding: 0 20px;
154
- height: 52px;
155
- background: var(--glass);
156
- backdrop-filter: var(--blur);
157
- -webkit-backdrop-filter: var(--blur);
158
- border-bottom: 1px solid var(--border);
159
- flex-shrink: 0;
160
- z-index: 100;
161
- }
162
-
163
- /* ---- Workspace ---- */
164
- .workspace {
165
- flex: 1;
166
- display: grid;
167
- grid-template-columns: 272px 1fr;
168
- gap: 0;
169
- min-height: 0;
170
- transition: grid-template-columns 0.4s cubic-bezier(.4, 0, .2, 1);
171
- }
172
-
173
- /* ---- Sidebar ---- */
174
- aside {
175
- background: var(--glass);
176
- backdrop-filter: var(--blur);
177
- -webkit-backdrop-filter: var(--blur);
178
- border-right: 1px solid var(--border);
179
- overflow-y: auto;
180
- overflow-x: hidden;
181
- display: flex;
182
- flex-direction: column;
183
- min-height: 0;
184
- z-index: 10;
185
- }
186
-
187
- /* ---- Main ---- */
188
- main {
189
- background: transparent;
190
- overflow: hidden;
191
- display: flex;
192
- flex-direction: column;
193
- min-height: 0;
194
- }
195
-
196
- /* ---- Footer / Stat Bar ---- */
197
- footer {
198
- display: flex;
199
- align-items: center;
200
- justify-content: space-between;
201
- padding: 0 20px;
202
- height: 40px;
203
- background: var(--glass);
204
- backdrop-filter: var(--blur);
205
- -webkit-backdrop-filter: var(--blur);
206
- border-top: 1px solid var(--border);
207
- flex-shrink: 0;
208
- z-index: 100;
209
- }
210
-
211
- footer .mono {
212
- font-family: var(--mono);
213
- font-size: 11px;
214
- color: var(--text3);
215
- letter-spacing: .02em;
216
- }
217
-
218
- .footer-left {
219
- font-size: 11px;
220
- color: var(--text3);
221
- }
222
-
223
- /* ---- Stat Chips in footer ---- */
224
- .stat-bar {
225
- display: flex;
226
- gap: 16px;
227
- align-items: center;
228
- }
229
-
230
- .stat-item {
231
- display: flex;
232
- align-items: baseline;
233
- gap: 6px;
234
- font-size: 11px;
235
- }
236
-
237
- .stat-item .stat-label {
238
- color: var(--text3);
239
- font-weight: 500;
240
- text-transform: uppercase;
241
- letter-spacing: .05em;
242
- font-size: 10px;
243
- }
244
-
245
- .stat-item .stat-value {
246
- font-family: var(--mono);
247
- font-size: 12px;
248
- color: var(--text);
249
- font-weight: 600;
250
- font-variant-numeric: tabular-nums;
251
- transition: color 0.3s ease;
252
- }
253
-
254
- .stat-item .stat-value.updated {
255
- color: var(--accent2);
256
- }
257
-
258
- /* =========================================
259
- Brand / Header
260
- ========================================= */
261
-
262
- .brand {
263
- display: flex;
264
- gap: 10px;
265
- align-items: center;
266
- min-width: 0;
267
- }
268
-
269
- .logo {
270
- width: 28px;
271
- height: 28px;
272
- border-radius: 7px;
273
- background: linear-gradient(135deg, #3b82f6, #6366f1);
274
- position: relative;
275
- flex-shrink: 0;
276
- box-shadow: 0 0 20px rgba(59, 130, 246, .2);
277
- }
278
-
279
- .logo::after {
280
- content: "";
281
- position: absolute;
282
- inset: 6px;
283
- border: 1.5px solid rgba(255,255,255,.8);
284
- border-radius: 3px;
285
- }
286
-
287
- .brand h1 {
288
- font-size: 14px;
289
- font-weight: 600;
290
- letter-spacing: -.01em;
291
- color: var(--text);
292
- }
293
-
294
- .brand .sub {
295
- font-size: 11px;
296
- color: var(--text3);
297
- margin-top: 0;
298
- line-height: 1.2;
299
- }
300
-
301
- .status-row {
302
- display: flex;
303
- gap: 8px;
304
- align-items: center;
305
- }
306
-
307
- /* =========================================
308
- Glass Pill / Indicators
309
- ========================================= */
310
-
311
- .pill {
312
- display: flex;
313
- align-items: center;
314
- gap: 7px;
315
- padding: 5px 12px;
316
- border-radius: var(--radius-md);
317
- border: 1px solid var(--border2);
318
- background: var(--surface);
319
- backdrop-filter: var(--blur);
320
- font-size: 12px;
321
- color: var(--text2);
322
- white-space: nowrap;
323
- transition: all 0.3s ease;
324
- }
325
-
326
- .dot {
327
- width: 6px;
328
- height: 6px;
329
- border-radius: 50%;
330
- background: var(--success);
331
- box-shadow: 0 0 8px rgba(52, 211, 153, .5);
332
- flex-shrink: 0;
333
- transition: all 0.4s ease;
334
- }
335
-
336
- .dot.warn {
337
- background: var(--warning);
338
- box-shadow: 0 0 8px rgba(251, 191, 36, .5);
339
- }
340
-
341
- .dot.bad {
342
- background: var(--danger);
343
- box-shadow: 0 0 8px rgba(248, 113, 113, .5);
344
- }
345
-
346
- /* Processing pulse on dot */
347
- .dot.processing {
348
- animation: dotPulse 1.5s ease-in-out infinite;
349
- }
350
-
351
- @keyframes dotPulse {
352
- 0%, 100% { transform: scale(1); opacity: 1; }
353
- 50% { transform: scale(1.6); opacity: .6; }
354
- }
355
-
356
- .kbd {
357
- font-family: var(--mono);
358
- font-size: 10px;
359
- font-weight: 600;
360
- padding: 2px 6px;
361
- border: 1px solid var(--border2);
362
- background: rgba(255,255,255,.04);
363
- border-radius: 4px;
364
- color: var(--text2);
365
- }
366
-
367
- .badge {
368
- display: inline-flex;
369
- align-items: center;
370
- gap: 5px;
371
- padding: 3px 8px;
372
- border-radius: var(--radius-sm);
373
- border: 1px solid var(--border2);
374
- background: var(--surface2);
375
- font-family: var(--mono);
376
- font-size: 11px;
377
- color: var(--text2);
378
- }
379
-
380
- /* =========================================
381
- Cards / Panels (Glass)
382
- ========================================= */
383
-
384
- .card {
385
- padding: 14px 16px;
386
- border-bottom: 1px solid var(--border);
387
- position: relative;
388
- }
389
-
390
- .card:last-child { border-bottom: none; }
391
-
392
- .card h2 {
393
- font-size: 10px;
394
- font-weight: 600;
395
- letter-spacing: .08em;
396
- text-transform: uppercase;
397
- color: var(--text3);
398
- margin-bottom: 2px;
399
- }
400
-
401
- .card small { color: var(--text2); }
402
-
403
- .card .hint {
404
- color: var(--text3);
405
- font-size: 11px;
406
- line-height: 1.4;
407
- margin-top: 5px;
408
- }
409
-
410
- .panel {
411
- background: var(--glass);
412
- backdrop-filter: var(--blur);
413
- -webkit-backdrop-filter: var(--blur);
414
- border: 1px solid var(--border);
415
- border-radius: var(--radius-lg);
416
- padding: 14px;
417
- box-shadow: var(--shadow-md), var(--shadow-glow);
418
- overflow: hidden;
419
- position: relative;
420
- transition: box-shadow 0.3s ease;
421
- }
422
-
423
- .panel:hover {
424
- box-shadow: var(--shadow-lg), 0 0 60px rgba(59, 130, 246, .06);
425
- }
426
-
427
- .panel h3 {
428
- margin: 0 0 10px;
429
- font-size: 10px;
430
- font-weight: 600;
431
- letter-spacing: .08em;
432
- text-transform: uppercase;
433
- color: var(--text3);
434
- display: flex;
435
- align-items: center;
436
- justify-content: space-between;
437
- gap: 8px;
438
- }
439
-
440
- .panel h3 .rightnote {
441
- font-size: 11px;
442
- color: var(--text3);
443
- font-family: var(--mono);
444
- letter-spacing: 0;
445
- text-transform: none;
446
- font-weight: 400;
447
- }
448
-
449
- .collapse-btn {
450
- background: rgba(255,255,255,.04);
451
- border: 1px solid var(--border2);
452
- border-radius: var(--radius-sm);
453
- padding: 4px 10px;
454
- color: var(--text3);
455
- cursor: pointer;
456
- font-size: 11px;
457
- font-family: var(--font);
458
- font-weight: 500;
459
- transition: all 0.2s ease;
460
- }
461
-
462
- .collapse-btn:hover {
463
- background: rgba(255,255,255,.08);
464
- color: var(--text2);
465
- border-color: var(--border3);
466
- }
467
-
468
- /* =========================================
469
- Inputs & Controls
470
- ========================================= */
471
-
472
- .grid2 {
473
- display: grid;
474
- grid-template-columns: 1fr 1fr;
475
- gap: 8px;
476
- margin-top: 10px;
477
- }
478
-
479
- .row {
480
- display: flex;
481
- gap: 8px;
482
- align-items: center;
483
- justify-content: space-between;
484
- margin-top: 8px;
485
- }
486
-
487
- label {
488
- font-size: 11px;
489
- font-weight: 500;
490
- color: var(--text3);
491
- }
492
-
493
- input[type="range"] { width: 100%; }
494
-
495
- select,
496
- textarea,
497
- input[type="text"],
498
- input[type="number"] {
499
- width: 100%;
500
- background: rgba(255,255,255,.03);
501
- border: 1px solid var(--border2);
502
- border-radius: var(--radius-sm);
503
- padding: 8px 10px;
504
- color: var(--text);
505
- outline: none;
506
- font-family: var(--font);
507
- font-size: 13px;
508
- transition: all 0.2s ease;
509
- }
510
-
511
- select:focus,
512
- textarea:focus,
513
- input[type="text"]:focus,
514
- input[type="number"]:focus {
515
- border-color: rgba(59, 130, 246, .5);
516
- box-shadow: 0 0 0 3px rgba(59, 130, 246, .1), 0 0 20px rgba(59, 130, 246, .05);
517
- background: rgba(255,255,255,.05);
518
- }
519
-
520
- textarea {
521
- resize: vertical;
522
- line-height: 1.4;
523
- }
524
-
525
- /* ---- Buttons ---- */
526
- .btn {
527
- user-select: none;
528
- cursor: pointer;
529
- border: none;
530
- border-radius: var(--radius-sm);
531
- padding: 9px 16px;
532
- font-weight: 600;
533
- font-size: 12px;
534
- font-family: var(--font);
535
- letter-spacing: .01em;
536
- color: #fff;
537
- background: linear-gradient(135deg, #3b82f6, #2563eb);
538
- box-shadow: 0 2px 8px rgba(59, 130, 246, .25), inset 0 1px 0 rgba(255,255,255,.1);
539
- transition: all 0.2s cubic-bezier(.4, 0, .2, 1);
540
- position: relative;
541
- overflow: hidden;
542
- }
543
-
544
- .btn::after {
545
- content: "";
546
- position: absolute;
547
- inset: 0;
548
- background: linear-gradient(180deg, rgba(255,255,255,.08), transparent);
549
- pointer-events: none;
550
- }
551
-
552
- .btn:hover {
553
- transform: translateY(-1px);
554
- box-shadow: 0 4px 16px rgba(59, 130, 246, .35), inset 0 1px 0 rgba(255,255,255,.15);
555
- }
556
-
557
- .btn:active {
558
- transform: translateY(0);
559
- box-shadow: 0 1px 4px rgba(59, 130, 246, .2);
560
- }
561
-
562
- .btn.secondary {
563
- background: rgba(255,255,255,.05);
564
- border: 1px solid var(--border2);
565
- box-shadow: none;
566
- color: var(--text2);
567
- font-weight: 500;
568
- }
569
-
570
- .btn.secondary::after { display: none; }
571
-
572
- .btn.secondary:hover {
573
- background: rgba(255,255,255,.08);
574
- color: var(--text);
575
- border-color: var(--border3);
576
- transform: translateY(-1px);
577
- box-shadow: var(--shadow-sm);
578
- }
579
-
580
- .btn.danger {
581
- background: linear-gradient(135deg, #dc2626, #b91c1c);
582
- box-shadow: 0 2px 8px rgba(220, 38, 38, .25);
583
- }
584
-
585
- .btn.danger:hover {
586
- box-shadow: 0 4px 16px rgba(220, 38, 38, .35);
587
- }
588
-
589
- .btnrow {
590
- display: flex;
591
- gap: 6px;
592
- margin-top: 10px;
593
- }
594
-
595
- .btnrow .btn { flex: 1; }
596
-
597
- /* =========================================
598
- Tabs
599
- ========================================= */
600
-
601
- .tabs {
602
- display: flex;
603
- gap: 2px;
604
- padding: 8px 12px;
605
- border-bottom: 1px solid var(--border);
606
- background: var(--glass);
607
- }
608
-
609
- .tabbtn {
610
- cursor: pointer;
611
- border: none;
612
- border-radius: var(--radius-sm);
613
- padding: 6px 14px;
614
- font-size: 12px;
615
- font-family: var(--font);
616
- font-weight: 500;
617
- color: var(--text3);
618
- background: transparent;
619
- transition: all 0.2s ease;
620
- }
621
-
622
- .tabbtn:hover {
623
- color: var(--text2);
624
- background: rgba(255,255,255,.04);
625
- }
626
-
627
- .tabbtn.active {
628
- color: var(--text);
629
- background: rgba(255,255,255,.06);
630
- }
631
-
632
- .tab {
633
- display: none;
634
- flex: 1;
635
- min-height: 0;
636
- overflow: auto;
637
- padding: 14px;
638
- }
639
-
640
- .tab.active { display: block; }
641
-
642
- /* =========================================
643
- Video Viewport (Cinematic)
644
- ========================================= */
645
-
646
- .viewbox {
647
- position: relative;
648
- border-radius: var(--radius-lg);
649
- overflow: hidden;
650
- background: #000;
651
- border: 1px solid var(--border);
652
- min-height: 360px;
653
- box-shadow: var(--shadow-lg), 0 0 80px rgba(0,0,0,.4);
654
- transition: box-shadow 0.4s ease;
655
- }
656
-
657
- /* Ambient glow behind video during processing */
658
- body.state-processing .viewbox {
659
- box-shadow: var(--shadow-lg), 0 0 60px rgba(59, 130, 246, .12), 0 0 120px rgba(59, 130, 246, .06);
660
- }
661
-
662
- body.state-complete .viewbox {
663
- box-shadow: var(--shadow-lg), 0 0 60px rgba(52, 211, 153, .08);
664
- }
665
-
666
- .viewbox canvas,
667
- .viewbox video {
668
- width: 100%;
669
- height: 100%;
670
- display: block;
671
- object-fit: contain;
672
- }
673
-
674
- #videoEngage { display: block; opacity: 1; }
675
-
676
- /* SVG overlay */
677
- #engageOverlay {
678
- position: absolute;
679
- top: 0; left: 0;
680
- width: 100%; height: 100%;
681
- pointer-events: auto;
682
- cursor: crosshair;
683
- z-index: 5;
684
- }
685
-
686
- #engageOverlay .track-box {
687
- cursor: pointer;
688
- transition: opacity 0.15s ease;
689
- }
690
-
691
- #engageOverlay .track-box:hover rect:first-child {
692
- fill: rgba(59, 130, 246, 0.08);
693
- }
694
-
695
- /* ---- Watermark ---- */
696
- .viewbox .watermark {
697
- position: absolute;
698
- left: 10px; bottom: 10px;
699
- font-family: var(--mono);
700
- font-size: 10px;
701
- color: rgba(255,255,255,.25);
702
- background: rgba(0,0,0,.5);
703
- backdrop-filter: blur(8px);
704
- padding: 4px 10px;
705
- border-radius: 6px;
706
- letter-spacing: .04em;
707
- border: 1px solid rgba(255,255,255,.06);
708
- }
709
-
710
- /* ---- Empty State ---- */
711
- .viewbox .empty {
712
- position: absolute;
713
- inset: 0;
714
- display: flex;
715
- flex-direction: column;
716
- align-items: center;
717
- justify-content: center;
718
- gap: 16px;
719
- color: var(--text3);
720
- text-align: center;
721
- padding: 40px;
722
- }
723
-
724
- .viewbox .empty .big {
725
- font-size: 16px;
726
- font-weight: 500;
727
- color: var(--text2);
728
- }
729
-
730
- .viewbox .empty .small {
731
- color: var(--text3);
732
- font-size: 13px;
733
- max-width: 420px;
734
- line-height: 1.6;
735
- }
736
-
737
- /* ---- Progress Ring (overlaid on video) ---- */
738
- .progress-ring-wrap {
739
- position: absolute;
740
- top: 50%; left: 50%;
741
- transform: translate(-50%, -50%);
742
- z-index: 20;
743
- display: none;
744
- flex-direction: column;
745
- align-items: center;
746
- gap: 12px;
747
- pointer-events: none;
748
- }
749
-
750
- .progress-ring-wrap.visible {
751
- display: flex;
752
- animation: fadeInScale 0.4s cubic-bezier(.4, 0, .2, 1);
753
- }
754
-
755
- @keyframes fadeInScale {
756
- from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
757
- to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
758
- }
759
-
760
- .progress-ring-wrap svg {
761
- width: 100px;
762
- height: 100px;
763
- transform: rotate(-90deg);
764
- filter: drop-shadow(0 0 12px rgba(59, 130, 246, .3));
765
- }
766
-
767
- .progress-ring-bg {
768
- fill: none;
769
- stroke: rgba(255,255,255,.06);
770
- stroke-width: 4;
771
- }
772
-
773
- .progress-ring-fg {
774
- fill: none;
775
- stroke: var(--accent);
776
- stroke-width: 4;
777
- stroke-linecap: round;
778
- stroke-dasharray: 264;
779
- stroke-dashoffset: 264;
780
- transition: stroke-dashoffset 0.5s cubic-bezier(.4, 0, .2, 1);
781
- }
782
-
783
- .progress-ring-text {
784
- font-family: var(--mono);
785
- font-size: 13px;
786
- font-weight: 600;
787
- color: var(--text);
788
- background: rgba(0,0,0,.5);
789
- backdrop-filter: blur(8px);
790
- padding: 4px 14px;
791
- border-radius: 20px;
792
- border: 1px solid var(--border2);
793
- letter-spacing: .02em;
794
- }
795
-
796
- /* =========================================
797
- Timeline
798
- ========================================= */
799
-
800
- .timeline-wrap {
801
- width: 100%;
802
- margin-top: 8px;
803
- border-radius: 4px;
804
- overflow: hidden;
805
- background: rgba(255,255,255,.03);
806
- cursor: pointer;
807
- position: relative;
808
- }
809
-
810
- .timeline-wrap canvas {
811
- display: block;
812
- width: 100%;
813
- height: 14px;
814
- }
815
-
816
- /* =========================================
817
- Track Cards (with animation)
818
- ========================================= */
819
-
820
- .list {
821
- display: flex;
822
- flex-direction: column;
823
- gap: 4px;
824
- min-height: 160px;
825
- max-height: none;
826
- overflow-y: auto;
827
- padding-right: 4px;
828
- }
829
-
830
- .obj {
831
- padding: 10px 12px;
832
- border-radius: var(--radius-sm);
833
- border: 1px solid var(--border);
834
- background: rgba(255,255,255,.02);
835
- cursor: pointer;
836
- transition: all 0.15s ease;
837
- }
838
-
839
- .obj:hover {
840
- background: rgba(255,255,255,.05);
841
- border-color: var(--border2);
842
- }
843
-
844
- .obj.active {
845
- border-color: var(--accent);
846
- box-shadow: inset 3px 0 0 var(--accent), 0 0 20px rgba(59,130,246,.06);
847
- background: rgba(59, 130, 246, .04);
848
- }
849
-
850
- .obj .top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
851
- .obj .id { font-family: var(--mono); font-size: 12px; color: var(--text); }
852
- .obj .cls { font-size: 12px; color: var(--text2); }
853
- .obj .meta { margin-top: 4px; display: flex; gap: 10px; flex-wrap: wrap; font-size: 11px; color: var(--text3); }
854
-
855
- /* ---- Track Cards ---- */
856
- .track-card {
857
- background: rgba(255,255,255,.02);
858
- border: 1px solid var(--border);
859
- border-radius: var(--radius-sm);
860
- padding: 10px 12px;
861
- cursor: pointer;
862
- transition: all 0.2s cubic-bezier(.4, 0, .2, 1);
863
- /* Staggered entrance */
864
- opacity: 0;
865
- transform: translateY(8px);
866
- animation: cardEnter 0.35s cubic-bezier(.4, 0, .2, 1) forwards;
867
- }
868
-
869
- @keyframes cardEnter {
870
- to { opacity: 1; transform: translateY(0); }
871
- }
872
-
873
- /* Stagger delays applied via JS: style="animation-delay: Xms" */
874
-
875
- .track-card:hover {
876
- background: rgba(255,255,255,.05);
877
- border-color: var(--border2);
878
- transform: translateY(-1px);
879
- box-shadow: var(--shadow-sm);
880
- }
881
-
882
- .track-card.active {
883
- border-color: var(--accent);
884
- background: rgba(59, 130, 246, .05);
885
- box-shadow: inset 3px 0 0 var(--accent), 0 0 24px rgba(59, 130, 246, .06);
886
- transform: translateY(0);
887
- }
888
-
889
- .track-card.inspected {
890
- border-left-color: rgba(96, 165, 250, 0.8);
891
- box-shadow: inset 3px 0 0 rgba(96, 165, 250, 0.8);
892
- }
893
-
894
- .track-card-header {
895
- display: flex;
896
- justify-content: space-between;
897
- align-items: center;
898
- font-weight: 600;
899
- margin-bottom: 4px;
900
- font-size: 13px;
901
- color: var(--text);
902
- }
903
-
904
- .track-card-meta {
905
- font-size: 11px;
906
- font-family: var(--mono);
907
- color: var(--text3);
908
- margin-bottom: 6px;
909
- letter-spacing: .02em;
910
- }
911
-
912
- .track-card-body {
913
- font-size: 12px;
914
- line-height: 1.45;
915
- color: var(--text2);
916
- background: rgba(0,0,0,.2);
917
- padding: 8px 10px;
918
- border-radius: var(--radius-sm);
919
- border: 1px solid var(--border);
920
- }
921
-
922
- .badgemini {
923
- font-size: 10px;
924
- font-weight: 600;
925
- padding: 2px 7px;
926
- border-radius: 4px;
927
- letter-spacing: .02em;
928
- line-height: 1;
929
- }
930
-
931
- .track-card-features {
932
- margin-top: 8px;
933
- border-top: 1px solid var(--border);
934
- padding-top: 8px;
935
- display: grid;
936
- grid-template-columns: 1fr 1fr;
937
- gap: 3px 12px;
938
- }
939
-
940
- .track-card-features .feat-row {
941
- display: flex;
942
- justify-content: space-between;
943
- align-items: baseline;
944
- font-size: 11px;
945
- padding: 2px 0;
946
- }
947
-
948
- .track-card-features .feat-key {
949
- font-family: var(--mono);
950
- color: var(--text3);
951
- white-space: nowrap;
952
- margin-right: 6px;
953
- }
954
-
955
- .track-card-features .feat-val {
956
- color: var(--text);
957
- text-align: right;
958
- font-size: 11px;
959
- font-variant-numeric: tabular-nums;
960
- }
961
-
962
- .gpt-badge {
963
- color: var(--accent2);
964
- font-size: 10px;
965
- border: 1px solid rgba(59,130,246,.3);
966
- border-radius: 3px;
967
- padding: 1px 4px;
968
- margin-left: 6px;
969
- }
970
-
971
- .gpt-text { color: var(--text2); }
972
-
973
- /* =========================================
974
- Tables
975
- ========================================= */
976
-
977
- .table {
978
- width: 100%;
979
- border-collapse: separate;
980
- border-spacing: 0;
981
- border-radius: var(--radius-md);
982
- border: 1px solid var(--border);
983
- overflow: hidden;
984
- }
985
-
986
- .table th, .table td {
987
- padding: 8px 10px;
988
- font-size: 12px;
989
- border-bottom: 1px solid var(--border);
990
- vertical-align: top;
991
- }
992
-
993
- .table th {
994
- background: rgba(255,255,255,.03);
995
- color: var(--text3);
996
- letter-spacing: .04em;
997
- text-transform: uppercase;
998
- font-size: 10px;
999
- font-weight: 600;
1000
- }
1001
-
1002
- .table tr:last-child td { border-bottom: none; }
1003
-
1004
- .k { font-family: var(--mono); color: var(--text); }
1005
- .mini { font-size: 11px; color: var(--text3); line-height: 1.4; }
1006
-
1007
- /* =========================================
1008
- Metrics & Log
1009
- ========================================= */
1010
-
1011
- .metricgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
1012
-
1013
- .metric {
1014
- border: 1px solid var(--border);
1015
- background: rgba(255,255,255,.02);
1016
- border-radius: var(--radius-md);
1017
- padding: 12px;
1018
- }
1019
-
1020
- .metric .label {
1021
- font-size: 10px;
1022
- color: var(--text3);
1023
- font-weight: 600;
1024
- letter-spacing: .05em;
1025
- text-transform: uppercase;
1026
- }
1027
-
1028
- .metric .value {
1029
- margin-top: 4px;
1030
- font-family: var(--mono);
1031
- font-size: 20px;
1032
- font-weight: 600;
1033
- color: var(--text);
1034
- font-variant-numeric: tabular-nums;
1035
- }
1036
-
1037
- .metric .sub {
1038
- margin-top: 4px;
1039
- font-size: 11px;
1040
- color: var(--text3);
1041
- }
1042
-
1043
- .log {
1044
- font-family: var(--mono);
1045
- font-size: 11px;
1046
- color: var(--text2);
1047
- line-height: 1.5;
1048
- background: rgba(0,0,0,.3);
1049
- border: 1px solid var(--border);
1050
- border-radius: var(--radius-sm);
1051
- padding: 10px;
1052
- height: 210px;
1053
- overflow: auto;
1054
- white-space: pre-wrap;
1055
- }
1056
-
1057
- .log .t { color: var(--accent2); }
1058
- .log .w { color: var(--warning); }
1059
- .log .e { color: var(--danger); }
1060
- .log .g { color: var(--success); }
1061
-
1062
- /* =========================================
1063
- Engage Grid
1064
- ========================================= */
1065
-
1066
- .engage-grid {
1067
- display: grid;
1068
- grid-template-columns: 1.6fr .9fr;
1069
- gap: 12px;
1070
- min-height: 0;
1071
- transition: grid-template-columns 0.4s cubic-bezier(.4, 0, .2, 1);
1072
- }
1073
-
1074
- .engage-grid.sidebar-collapsed {
1075
- grid-template-columns: 1fr 0fr;
1076
- }
1077
-
1078
- .engage-grid.sidebar-collapsed .engage-right { display: none; }
1079
-
1080
- .engage-right {
1081
- display: flex;
1082
- flex-direction: column;
1083
- gap: 12px;
1084
- min-height: 0;
1085
- }
1086
-
1087
- /* =========================================
1088
- Chips / Strip
1089
- ========================================= */
1090
-
1091
- .strip {
1092
- display: flex;
1093
- gap: 6px;
1094
- flex-wrap: wrap;
1095
- align-items: center;
1096
- }
1097
-
1098
- .strip .chip {
1099
- padding: 4px 10px;
1100
- border-radius: 6px;
1101
- border: 1px solid var(--border);
1102
- background: rgba(255,255,255,.03);
1103
- font-family: var(--mono);
1104
- font-size: 11px;
1105
- color: var(--text2);
1106
- transition: all 0.2s ease;
1107
- cursor: default;
1108
- }
1109
-
1110
- .strip .chip:hover {
1111
- background: rgba(255,255,255,.06);
1112
- border-color: var(--border2);
1113
- }
1114
-
1115
- /* =========================================
1116
- Sidebar Checkbox
1117
- ========================================= */
1118
-
1119
- .checkbox-row {
1120
- grid-column: span 2;
1121
- margin-top: 8px;
1122
- border-top: 1px solid var(--border);
1123
- padding-top: 8px;
1124
- display: flex;
1125
- align-items: center;
1126
- gap: 8px;
1127
- cursor: pointer;
1128
- font-size: 12px;
1129
- }
1130
-
1131
- .checkbox-row input[type="checkbox"] {
1132
- width: auto;
1133
- margin: 0;
1134
- accent-color: var(--accent);
1135
- }
1136
-
1137
- /* =========================================
1138
- Progress Bar
1139
- ========================================= */
1140
-
1141
- .bar {
1142
- height: 4px;
1143
- border-radius: 2px;
1144
- background: rgba(255,255,255,.04);
1145
- overflow: hidden;
1146
- }
1147
-
1148
- .bar > div {
1149
- height: 100%;
1150
- width: 0%;
1151
- background: linear-gradient(90deg, var(--accent), var(--accent2));
1152
- transition: width .3s ease;
1153
- border-radius: 2px;
1154
- }
1155
-
1156
- /* =========================================
1157
- Frame / Intel Grid
1158
- ========================================= */
1159
-
1160
- .frame-grid {
1161
- display: grid;
1162
- grid-template-columns: 1.6fr 0.9fr;
1163
- grid-template-rows: auto auto;
1164
- gap: 12px;
1165
- min-height: 0;
1166
- }
1167
-
1168
- .frame-grid .panel-monitor { grid-column: 1; grid-row: 1 / 3; }
1169
- .frame-grid .panel-summary { grid-column: 2; grid-row: 1 / 3; }
1170
-
1171
- .intel { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
1172
- .intel-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
1173
-
1174
- .thumbrow { display: flex; gap: 8px; }
1175
- .thumbrow img {
1176
- flex: 1;
1177
- height: 86px;
1178
- object-fit: cover;
1179
- border-radius: var(--radius-sm);
1180
- border: 1px solid var(--border);
1181
- background: rgba(0,0,0,.2);
1182
- }
1183
-
1184
- .intelbox {
1185
- font-size: 12px;
1186
- line-height: 1.45;
1187
- color: var(--text2);
1188
- background: rgba(0,0,0,.2);
1189
- border: 1px solid var(--border);
1190
- border-radius: var(--radius-sm);
1191
- padding: 10px;
1192
- min-height: 72px;
1193
- }
1194
-
1195
- /* =========================================
1196
- Radar / Trade Space
1197
- ========================================= */
1198
-
1199
- .radar { height: 540px; display: flex; flex-direction: column; }
1200
- .radar canvas { flex: 1; width: 100%; height: 100%; display: block; }
1201
-
1202
- .trade-grid {
1203
- display: grid;
1204
- grid-template-columns: 1.35fr .65fr;
1205
- gap: 12px;
1206
- min-height: 0;
1207
- }
1208
-
1209
- .plot { height: 420px; }
1210
-
1211
- /* =========================================
1212
- Chat Panel (Glass)
1213
- ========================================= */
1214
-
1215
- .panel-chat {
1216
- grid-column: 2;
1217
- grid-row: 2;
1218
- max-height: 480px;
1219
- min-height: 320px;
1220
- display: flex;
1221
- flex-direction: column;
1222
- }
1223
-
1224
- .panel-chat.collapsed .chat-container { display: none; }
1225
- .panel-chat.collapsed { max-height: 44px; min-height: 0; }
1226
-
1227
- .chat-container {
1228
- display: flex;
1229
- flex-direction: column;
1230
- flex: 1;
1231
- min-height: 0;
1232
- gap: 8px;
1233
- }
1234
-
1235
- .chat-messages {
1236
- flex: 1;
1237
- overflow-y: auto;
1238
- background: rgba(0,0,0,.2);
1239
- border: 1px solid var(--border);
1240
- border-radius: var(--radius-sm);
1241
- padding: 10px;
1242
- display: flex;
1243
- flex-direction: column;
1244
- gap: 6px;
1245
- min-height: 100px;
1246
- }
1247
-
1248
- .chat-message {
1249
- display: flex;
1250
- gap: 8px;
1251
- padding: 8px 12px;
1252
- border-radius: var(--radius-sm);
1253
- font-size: 13px;
1254
- line-height: 1.45;
1255
- animation: msgEnter 0.25s cubic-bezier(.4, 0, .2, 1);
1256
- }
1257
-
1258
- @keyframes msgEnter {
1259
- from { opacity: 0; transform: translateY(6px); }
1260
- to { opacity: 1; transform: translateY(0); }
1261
- }
1262
-
1263
- .chat-message.chat-user {
1264
- background: rgba(59, 130, 246, .08);
1265
- border: 1px solid rgba(59, 130, 246, .15);
1266
- margin-left: 16px;
1267
- }
1268
-
1269
- .chat-message.chat-assistant {
1270
- background: rgba(255,255,255,.03);
1271
- border: 1px solid var(--border);
1272
- margin-right: 16px;
1273
- }
1274
-
1275
- .chat-message.chat-system {
1276
- background: rgba(251, 191, 36, .06);
1277
- border: 1px solid rgba(251, 191, 36, .12);
1278
- color: var(--warning);
1279
- font-size: 12px;
1280
- }
1281
-
1282
- .chat-message.loading .chat-content::after {
1283
- content: "";
1284
- display: inline-block;
1285
- width: 12px; height: 12px;
1286
- border: 2px solid rgba(255,255,255,.1);
1287
- border-top-color: var(--accent);
1288
- border-radius: 50%;
1289
- animation: spin 0.7s linear infinite;
1290
- margin-left: 8px;
1291
- vertical-align: middle;
1292
- }
1293
-
1294
- @keyframes spin { to { transform: rotate(360deg); } }
1295
-
1296
- .chat-icon {
1297
- flex-shrink: 0;
1298
- font-size: 11px;
1299
- font-weight: 600;
1300
- color: var(--text3);
1301
- }
1302
-
1303
- .chat-content {
1304
- flex: 1;
1305
- color: var(--text);
1306
- word-break: break-word;
1307
- }
1308
-
1309
- .chat-input-row {
1310
- display: flex;
1311
- gap: 6px;
1312
- }
1313
-
1314
- .chat-input-row input {
1315
- flex: 1;
1316
- background: rgba(255,255,255,.03);
1317
- border: 1px solid var(--border2);
1318
- border-radius: var(--radius-sm);
1319
- padding: 10px 12px;
1320
- color: var(--text);
1321
- font-family: var(--font);
1322
- font-size: 13px;
1323
- outline: none;
1324
- transition: all 0.2s ease;
1325
- }
1326
-
1327
- .chat-input-row input:focus {
1328
- border-color: rgba(59, 130, 246, .5);
1329
- box-shadow: 0 0 0 3px rgba(59, 130, 246, .1);
1330
- background: rgba(255,255,255,.05);
1331
- }
1332
-
1333
- .chat-input-row input:disabled {
1334
- opacity: 0.3;
1335
- cursor: not-allowed;
1336
- }
1337
-
1338
- .chat-input-row .btn {
1339
- padding: 10px 18px;
1340
- min-width: 70px;
1341
- }
1342
-
1343
- /* Chat Context Header */
1344
- .chat-context-header {
1345
- display: flex;
1346
- align-items: center;
1347
- gap: 6px;
1348
- padding: 6px 12px;
1349
- background: rgba(0,0,0,.25);
1350
- border-bottom: 1px solid var(--border);
1351
- flex-wrap: wrap;
1352
- flex-shrink: 0;
1353
- }
1354
-
1355
- .chat-context-label {
1356
- color: var(--text3);
1357
- font-size: 10px;
1358
- text-transform: uppercase;
1359
- letter-spacing: 0.5px;
1360
- flex-shrink: 0;
1361
- }
1362
-
1363
- .chat-context-chips {
1364
- display: flex;
1365
- flex-wrap: wrap;
1366
- gap: 4px;
1367
- }
1368
-
1369
- .chat-context-chip {
1370
- display: inline-flex;
1371
- align-items: center;
1372
- gap: 4px;
1373
- background: rgba(255,255,255,.04);
1374
- border: 1px solid var(--border2);
1375
- color: var(--text);
1376
- padding: 2px 8px;
1377
- border-radius: 4px;
1378
- font-size: 11px;
1379
- font-family: var(--mono);
1380
- animation: msgEnter 0.2s ease;
1381
- }
1382
-
1383
- .chat-context-chip .chip-label {
1384
- color: var(--text2);
1385
- }
1386
-
1387
- .chat-context-chip .chip-status {
1388
- font-size: 9px;
1389
- line-height: 1;
1390
- }
1391
-
1392
- .chat-context-chip .chip-status.match { color: #34d399; }
1393
- .chat-context-chip .chip-status.no-match { color: #f87171; }
1394
- .chat-context-chip .chip-status.unassessed { color: var(--text3); }
1395
-
1396
- /* Chat Suggestions */
1397
- .chat-suggestions {
1398
- display: flex;
1399
- gap: 5px;
1400
- padding: 6px 12px;
1401
- border-bottom: 1px solid var(--border);
1402
- flex-wrap: wrap;
1403
- flex-shrink: 0;
1404
- overflow-x: auto;
1405
- }
1406
-
1407
- .chat-suggestion-chip {
1408
- background: rgba(255,255,255,.03);
1409
- border: 1px solid var(--border2);
1410
- color: var(--text2);
1411
- padding: 3px 10px;
1412
- border-radius: 12px;
1413
- font-size: 10px;
1414
- cursor: pointer;
1415
- white-space: nowrap;
1416
- transition: all 0.15s ease;
1417
- animation: msgEnter 0.2s ease;
1418
- }
1419
-
1420
- .chat-suggestion-chip:hover {
1421
- background: rgba(59, 130, 246, .1);
1422
- border-color: rgba(59, 130, 246, .3);
1423
- color: var(--text);
1424
- }
1425
-
1426
- /* Chat context marker (join/leave) */
1427
- .chat-message.chat-marker {
1428
- background: none;
1429
- border: none;
1430
- justify-content: center;
1431
- color: var(--text3);
1432
- font-size: 11px;
1433
- padding: 4px 0;
1434
- animation: msgEnter 0.2s ease;
1435
- }
1436
-
1437
- /* =========================================
1438
- Inspection Panel — Quad View
1439
- ========================================= */
1440
-
1441
- .panel-inspection {
1442
- grid-column: 1;
1443
- grid-row: 2;
1444
- max-height: 520px;
1445
- min-height: 360px;
1446
- display: flex;
1447
- flex-direction: column;
1448
- animation: panelSlideUp 0.3s cubic-bezier(.4, 0, .2, 1);
1449
- }
1450
-
1451
- @keyframes panelSlideUp {
1452
- from { opacity: 0; transform: translateY(12px); }
1453
- to { opacity: 1; transform: translateY(0); }
1454
- }
1455
-
1456
- /* --- Header --- */
1457
-
1458
- .inspection-header {
1459
- display: flex;
1460
- justify-content: space-between;
1461
- align-items: center;
1462
- padding: 10px 12px;
1463
- border-bottom: 1px solid rgba(255,255,255,0.04);
1464
- }
1465
-
1466
- .inspection-header-left {
1467
- display: flex;
1468
- align-items: center;
1469
- gap: 12px;
1470
- }
1471
-
1472
- .inspection-obj-name {
1473
- font-size: 14px;
1474
- font-weight: 700;
1475
- color: #e2e8f0;
1476
- letter-spacing: 0.5px;
1477
- font-family: var(--mono);
1478
- }
1479
-
1480
- .inspection-conf {
1481
- font-size: 10px;
1482
- color: #64748b;
1483
- font-family: var(--mono);
1484
- }
1485
-
1486
- .inspection-status {
1487
- font-size: 8px;
1488
- padding: 2px 8px;
1489
- border-radius: 2px;
1490
- letter-spacing: 0.5px;
1491
- font-family: var(--mono);
1492
- font-weight: 600;
1493
- }
1494
- .inspection-status.match {
1495
- background: rgba(34,197,94,0.12);
1496
- border: 1px solid rgba(34,197,94,0.2);
1497
- color: #22c55e;
1498
- }
1499
- .inspection-status.no-match {
1500
- background: rgba(239,68,68,0.12);
1501
- border: 1px solid rgba(239,68,68,0.2);
1502
- color: #ef4444;
1503
- }
1504
- .inspection-status.pending {
1505
- background: rgba(255,255,255,0.05);
1506
- border: 1px solid rgba(255,255,255,0.08);
1507
- color: #64748b;
1508
- }
1509
-
1510
- .inspection-header-right {
1511
- display: flex;
1512
- align-items: center;
1513
- gap: 10px;
1514
- }
1515
-
1516
- .inspection-frame {
1517
- font-size: 9px;
1518
- color: #64748b;
1519
- letter-spacing: 1px;
1520
- font-family: var(--mono);
1521
- }
1522
-
1523
- /* --- Quad Grid --- */
1524
-
1525
- .inspection-quad {
1526
- display: grid;
1527
- grid-template-columns: 1fr 1fr;
1528
- grid-template-rows: 1fr 1fr;
1529
- gap: 8px;
1530
- flex: 1;
1531
- min-height: 300px;
1532
- position: relative;
1533
- padding: 8px 12px;
1534
- }
1535
-
1536
- .inspection-quadrant {
1537
- background: rgba(0,0,0,0.5);
1538
- border: 1px solid rgba(74,158,255,0.1);
1539
- border-radius: 5px;
1540
- position: relative;
1541
- overflow: hidden;
1542
- cursor: pointer;
1543
- transition: all 0.25s ease;
1544
- }
1545
-
1546
- .inspection-quadrant:hover {
1547
- border-color: rgba(74,158,255,0.2);
1548
- }
1549
-
1550
- .inspection-quadrant.expanded {
1551
- position: absolute;
1552
- inset: 8px;
1553
- z-index: 10;
1554
- border-radius: 5px;
1555
- }
1556
-
1557
- /* --- Corner Brackets --- */
1558
-
1559
- .inspection-quadrant::before {
1560
- content: '';
1561
- position: absolute;
1562
- top: 6px;
1563
- right: 6px;
1564
- width: 14px;
1565
- height: 14px;
1566
- border-top: 1.5px solid rgba(74,158,255,0.25);
1567
- border-right: 1.5px solid rgba(74,158,255,0.25);
1568
- pointer-events: none;
1569
- z-index: 1;
1570
- }
1571
-
1572
- .inspection-quadrant::after {
1573
- content: '';
1574
- position: absolute;
1575
- bottom: 6px;
1576
- left: 6px;
1577
- width: 14px;
1578
- height: 14px;
1579
- border-bottom: 1.5px solid rgba(74,158,255,0.25);
1580
- border-left: 1.5px solid rgba(74,158,255,0.25);
1581
- pointer-events: none;
1582
- z-index: 1;
1583
- }
1584
-
1585
- #quadSeg::before, #quadSeg::after { border-color: rgba(74,158,255,0.25); }
1586
- #quadEdge::before, #quadEdge::after { border-color: rgba(148,163,184,0.2); }
1587
- #quadDepth::before, #quadDepth::after { border-color: rgba(245,158,11,0.2); }
1588
- #quad3d::before, #quad3d::after { border-color: rgba(34,197,94,0.2); }
1589
-
1590
- /* --- Quadrant Labels & Metrics --- */
1591
-
1592
- .quad-label {
1593
- position: absolute;
1594
- top: 8px;
1595
- left: 10px;
1596
- z-index: 1;
1597
- display: flex;
1598
- align-items: center;
1599
- gap: 6px;
1600
- font-size: 8px;
1601
- letter-spacing: 2px;
1602
- font-family: var(--mono);
1603
- pointer-events: none;
1604
- }
1605
-
1606
- .quad-dot {
1607
- width: 5px;
1608
- height: 5px;
1609
- border-radius: 50%;
1610
- }
1611
-
1612
- .quad-dot-seg { background: #4a9eff; box-shadow: 0 0 4px rgba(74,158,255,0.5); }
1613
- .quad-dot-edge { background: #94a3b8; box-shadow: 0 0 4px rgba(148,163,184,0.5); }
1614
- .quad-dot-depth { background: #f59e0b; box-shadow: 0 0 4px rgba(245,158,11,0.5); }
1615
- .quad-dot-3d { background: #22c55e; box-shadow: 0 0 4px rgba(34,197,94,0.5); }
1616
-
1617
- #quadSeg .quad-label { color: rgba(74,158,255,0.6); }
1618
- #quadEdge .quad-label { color: rgba(148,163,184,0.6); }
1619
- #quadDepth .quad-label { color: rgba(245,158,11,0.6); }
1620
- #quad3d .quad-label { color: rgba(34,197,94,0.6); }
1621
-
1622
- .quad-metric {
1623
- position: absolute;
1624
- bottom: 8px;
1625
- right: 10px;
1626
- font-size: 8px;
1627
- font-family: var(--mono);
1628
- pointer-events: none;
1629
- }
1630
-
1631
- #quadSeg .quad-metric { color: rgba(74,158,255,0.35); }
1632
- #quadEdge .quad-metric { color: rgba(148,163,184,0.35); }
1633
- #quadDepth .quad-metric { color: rgba(245,158,11,0.35); }
1634
- #quad3d .quad-metric { color: rgba(34,197,94,0.35); }
1635
-
1636
- .quad-canvas {
1637
- width: 100%;
1638
- height: 100%;
1639
- display: block;
1640
- object-fit: contain;
1641
- }
1642
-
1643
- .quad-3d-container {
1644
- position: absolute;
1645
- inset: 0;
1646
- z-index: 2;
1647
- }
1648
-
1649
- .quad-3d-container canvas {
1650
- width: 100% !important;
1651
- height: 100% !important;
1652
- }
1653
-
1654
- /* --- 3D On-Demand Prompt --- */
1655
-
1656
- .quad-3d-prompt {
1657
- position: absolute;
1658
- inset: 0;
1659
- display: flex;
1660
- flex-direction: column;
1661
- align-items: center;
1662
- justify-content: center;
1663
- gap: 8px;
1664
- z-index: 3;
1665
- }
1666
-
1667
- .quad-3d-prompt-icon {
1668
- color: rgba(34,197,94,0.4);
1669
- margin-bottom: 4px;
1670
- }
1671
-
1672
- .quad-3d-prompt-label {
1673
- font: 10px/1 var(--mono);
1674
- color: rgba(255,255,255,0.4);
1675
- letter-spacing: 1px;
1676
- }
1677
-
1678
- .quad-3d-btn {
1679
- padding: 6px 16px;
1680
- border: 1px solid rgba(34,197,94,0.3);
1681
- border-radius: 4px;
1682
- background: rgba(34,197,94,0.08);
1683
- color: rgba(34,197,94,0.8);
1684
- font: bold 10px/1 var(--mono);
1685
- letter-spacing: 0.5px;
1686
- cursor: pointer;
1687
- transition: all 0.15s ease;
1688
- }
1689
-
1690
- .quad-3d-btn:hover {
1691
- background: rgba(34,197,94,0.18);
1692
- border-color: rgba(34,197,94,0.5);
1693
- color: #22c55e;
1694
- }
1695
-
1696
- .quad-3d-btn-gen {
1697
- border-color: rgba(168,85,247,0.3);
1698
- background: rgba(168,85,247,0.08);
1699
- color: rgba(168,85,247,0.7);
1700
- }
1701
-
1702
- .quad-3d-btn-gen:hover {
1703
- background: rgba(168,85,247,0.18);
1704
- border-color: rgba(168,85,247,0.5);
1705
- color: #a855f7;
1706
- }
1707
-
1708
- .quad-3d-prompt-hint {
1709
- font: 8px/1 var(--mono);
1710
- color: rgba(255,255,255,0.2);
1711
- margin-top: 2px;
1712
- }
1713
-
1714
- .quad-3d-status {
1715
- position: absolute;
1716
- inset: 0;
1717
- display: flex;
1718
- flex-direction: column;
1719
- align-items: center;
1720
- justify-content: center;
1721
- gap: 12px;
1722
- z-index: 3;
1723
- }
1724
-
1725
- .quad-3d-status-text {
1726
- font: 10px/1.4 var(--mono);
1727
- color: rgba(255,255,255,0.5);
1728
- letter-spacing: 0.5px;
1729
- }
1730
-
1731
- /* --- Per-Quadrant Loading/Error --- */
1732
-
1733
- .quad-loading {
1734
- position: absolute;
1735
- inset: 0;
1736
- display: flex;
1737
- align-items: center;
1738
- justify-content: center;
1739
- background: rgba(0,0,0,0.5);
1740
- backdrop-filter: blur(2px);
1741
- z-index: 5;
1742
- }
1743
-
1744
- .quad-error {
1745
- position: absolute;
1746
- inset: 0;
1747
- display: flex;
1748
- align-items: center;
1749
- justify-content: center;
1750
- background: rgba(0,0,0,0.5);
1751
- z-index: 5;
1752
- color: var(--danger);
1753
- font-size: 10px;
1754
- font-family: var(--mono);
1755
- padding: 10px;
1756
- text-align: center;
1757
- }
1758
-
1759
- /* --- Bottom Metrics Strip --- */
1760
-
1761
- .inspection-metrics {
1762
- display: flex;
1763
- gap: 24px;
1764
- padding: 8px 12px;
1765
- border-top: 1px solid rgba(255,255,255,0.04);
1766
- font-size: 9px;
1767
- color: #64748b;
1768
- font-family: var(--mono);
1769
- }
1770
-
1771
- .inspection-metrics span {
1772
- color: #e2e8f0;
1773
- }
1774
-
1775
- /* --- Retained --- */
1776
-
1777
- .inspection-spinner {
1778
- width: 20px; height: 20px;
1779
- border: 2px solid rgba(255,255,255,.08);
1780
- border-top-color: var(--accent);
1781
- border-radius: 50%;
1782
- animation: spin 0.7s linear infinite;
1783
- }
1784
-
1785
- .inspection-empty {
1786
- position: absolute; inset: 0;
1787
- display: flex; align-items: center; justify-content: center;
1788
- color: var(--text3);
1789
- font-size: 12px;
1790
- text-align: center;
1791
- padding: 20px;
1792
- }
1793
-
1794
- .engage-grid.sidebar-collapsed .panel-inspection { grid-column: 1; }
1795
- .engage-grid.sidebar-collapsed .panel-chat { grid-column: 2; }
1796
-
1797
- /* =========================================
1798
- Toast Notification System
1799
- ========================================= */
1800
-
1801
- .toast-container {
1802
- position: fixed;
1803
- bottom: 56px;
1804
- right: 20px;
1805
- z-index: 9999;
1806
- display: flex;
1807
- flex-direction: column-reverse;
1808
- gap: 8px;
1809
- pointer-events: none;
1810
- max-width: 360px;
1811
- }
1812
-
1813
- .toast {
1814
- pointer-events: auto;
1815
- display: flex;
1816
- align-items: center;
1817
- gap: 10px;
1818
- padding: 10px 16px;
1819
- background: var(--glass);
1820
- backdrop-filter: var(--blur);
1821
- -webkit-backdrop-filter: var(--blur);
1822
- border: 1px solid var(--border2);
1823
- border-radius: var(--radius-md);
1824
- box-shadow: var(--shadow-lg);
1825
- font-size: 12px;
1826
- color: var(--text);
1827
- animation: toastIn 0.35s cubic-bezier(.4, 0, .2, 1);
1828
- transition: all 0.3s ease;
1829
- overflow: hidden;
1830
- }
1831
-
1832
- .toast.exiting {
1833
- animation: toastOut 0.25s ease forwards;
1834
- }
1835
-
1836
- @keyframes toastIn {
1837
- from { opacity: 0; transform: translateX(40px) scale(0.95); }
1838
- to { opacity: 1; transform: translateX(0) scale(1); }
1839
- }
1840
-
1841
- @keyframes toastOut {
1842
- to { opacity: 0; transform: translateX(40px) scale(0.95); }
1843
- }
1844
-
1845
- .toast-icon {
1846
- width: 6px; height: 6px;
1847
- border-radius: 50%;
1848
- flex-shrink: 0;
1849
- }
1850
-
1851
- .toast-icon.success { background: var(--success); box-shadow: 0 0 8px rgba(52,211,153,.5); }
1852
- .toast-icon.warning { background: var(--warning); box-shadow: 0 0 8px rgba(251,191,36,.5); }
1853
- .toast-icon.error { background: var(--danger); box-shadow: 0 0 8px rgba(248,113,113,.5); }
1854
- .toast-icon.info { background: var(--accent); box-shadow: 0 0 8px rgba(59,130,246,.5); }
1855
-
1856
- .toast-text { flex: 1; line-height: 1.4; }
1857
-
1858
- .toast-progress {
1859
- position: absolute;
1860
- bottom: 0; left: 0;
1861
- height: 2px;
1862
- background: var(--accent);
1863
- border-radius: 0 0 0 var(--radius-md);
1864
- animation: toastProgress 4s linear forwards;
1865
- }
1866
-
1867
- @keyframes toastProgress {
1868
- from { width: 100%; }
1869
- to { width: 0; }
1870
- }
1871
-
1872
- /* =========================================
1873
- Scrollbars
1874
- ========================================= */
1875
-
1876
- ::-webkit-scrollbar { width: 5px; height: 5px; }
1877
- ::-webkit-scrollbar-track { background: transparent; }
1878
- ::-webkit-scrollbar-thumb { background: rgba(255,255,255,.06); border-radius: 3px; }
1879
- ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.12); }
1880
-
1881
- /* =========================================
1882
- Utility
1883
- ========================================= */
1884
-
1885
- .mt-xs { margin-top: 4px; }
1886
- .mt-sm { margin-top: 6px; }
1887
- .mt-md { margin-top: 12px; }
1888
-
1889
- /* Skeleton loading shimmer */
1890
- .skeleton {
1891
- background: linear-gradient(90deg, rgba(255,255,255,.03) 25%, rgba(255,255,255,.06) 50%, rgba(255,255,255,.03) 75%);
1892
- background-size: 200% 100%;
1893
- animation: shimmer 1.5s ease-in-out infinite;
1894
- border-radius: var(--radius-sm);
1895
- }
1896
-
1897
- @keyframes shimmer {
1898
- 0% { background-position: 200% 0; }
1899
- 100% { background-position: -200% 0; }
1900
- }
1901
-
1902
- /* Number transition for animated counters */
1903
- .counter-value {
1904
- font-variant-numeric: tabular-nums;
1905
- transition: color 0.3s ease;
1906
- display: inline-block;
1907
- }
1908
-
1909
- .counter-value.tick {
1910
- color: var(--accent2);
1911
- }
1912
-
1913
- /* Focus visible ring */
1914
- :focus-visible {
1915
- outline: 2px solid var(--accent);
1916
- outline-offset: 2px;
1917
- }
1918
-
1919
- /* =========================================
1920
- Selection
1921
- ========================================= */
1922
-
1923
- ::selection {
1924
- background: rgba(59, 130, 246, .25);
1925
- color: #fff;
1926
- }
1927
-
1928
- /* ── Explainability Graph ─────────────────────────────────────── */
1929
-
1930
- .interpretability-graph {
1931
- position: relative;
1932
- width: 100%;
1933
- min-height: 60px;
1934
- background: #0a0e1a;
1935
- border-top: 1px solid #1e293b;
1936
- overflow: visible;
1937
- }
1938
-
1939
- .explain-svg {
1940
- display: block;
1941
- width: 100%;
1942
- font-family: 'Inter', -apple-system, sans-serif;
1943
- }
1944
-
1945
- .explain-svg text {
1946
- pointer-events: none;
1947
- user-select: none;
1948
- }
1949
-
1950
- .explain-svg g {
1951
- cursor: pointer;
1952
- }
1953
-
1954
- /* Loading state */
1955
- .explain-loading {
1956
- display: flex;
1957
- align-items: center;
1958
- justify-content: center;
1959
- gap: 12px;
1960
- padding: 32px 16px;
1961
- color: #94a3b8;
1962
- font-size: 12px;
1963
- }
1964
-
1965
- .explain-spinner {
1966
- width: 18px;
1967
- height: 18px;
1968
- border: 2px solid #334155;
1969
- border-top-color: #7c3aed;
1970
- border-radius: 50%;
1971
- animation: explain-spin 0.8s linear infinite;
1972
- }
1973
-
1974
- @keyframes explain-spin {
1975
- to { transform: rotate(360deg); }
1976
- }
1977
-
1978
- .explain-loading span {
1979
- animation: explain-pulse 2s ease-in-out infinite;
1980
- }
1981
-
1982
- @keyframes explain-pulse {
1983
- 0%, 100% { opacity: 0.6; }
1984
- 50% { opacity: 1; }
1985
- }
1986
-
1987
- /* Error state */
1988
- .explain-error {
1989
- padding: 24px 16px;
1990
- color: #f87171;
1991
- font-size: 11px;
1992
- text-align: center;
1993
- }
1994
-
1995
- /* Tooltip */
1996
- .explain-tooltip {
1997
- position: absolute;
1998
- z-index: 1000;
1999
- background: #1e293b;
2000
- border: 1px solid #334155;
2001
- border-radius: 6px;
2002
- padding: 10px 12px;
2003
- max-width: 280px;
2004
- font-size: 11px;
2005
- color: #e2e8f0;
2006
- line-height: 1.5;
2007
- pointer-events: none;
2008
- box-shadow: 0 4px 12px rgba(0,0,0,0.4);
2009
- }
2010
-
2011
- .explain-tooltip strong {
2012
- color: #f8fafc;
2013
- font-size: 12px;
2014
- }
2015
-
2016
- .tip-section {
2017
- margin-top: 6px;
2018
- padding-top: 4px;
2019
- border-top: 1px solid #334155;
2020
- }
2021
-
2022
- .tip-label {
2023
- font-weight: 600;
2024
- color: #94a3b8;
2025
- }
2026
-
2027
- .tip-agree .tip-label { color: #4ade80; }
2028
- .tip-disagree .tip-label { color: #f87171; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/vendor/GLTFLoader.js DELETED
The diff for this file is too large to render. See raw diff
 
frontend/vendor/OrbitControls.js DELETED
@@ -1,1101 +0,0 @@
1
- ( function () {
2
-
3
- // This set of controls performs orbiting, dollying (zooming), and panning.
4
- // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
5
- //
6
- // Orbit - left mouse / touch: one-finger move
7
- // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
8
- // Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move
9
-
10
- const _changeEvent = {
11
- type: 'change'
12
- };
13
- const _startEvent = {
14
- type: 'start'
15
- };
16
- const _endEvent = {
17
- type: 'end'
18
- };
19
- class OrbitControls extends THREE.EventDispatcher {
20
-
21
- constructor( object, domElement ) {
22
-
23
- super();
24
- this.object = object;
25
- this.domElement = domElement;
26
- this.domElement.style.touchAction = 'none'; // disable touch scroll
27
-
28
- // Set to false to disable this control
29
- this.enabled = true;
30
-
31
- // "target" sets the location of focus, where the object orbits around
32
- this.target = new THREE.Vector3();
33
-
34
- // How far you can dolly in and out ( PerspectiveCamera only )
35
- this.minDistance = 0;
36
- this.maxDistance = Infinity;
37
-
38
- // How far you can zoom in and out ( OrthographicCamera only )
39
- this.minZoom = 0;
40
- this.maxZoom = Infinity;
41
-
42
- // How far you can orbit vertically, upper and lower limits.
43
- // Range is 0 to Math.PI radians.
44
- this.minPolarAngle = 0; // radians
45
- this.maxPolarAngle = Math.PI; // radians
46
-
47
- // How far you can orbit horizontally, upper and lower limits.
48
- // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
49
- this.minAzimuthAngle = - Infinity; // radians
50
- this.maxAzimuthAngle = Infinity; // radians
51
-
52
- // Set to true to enable damping (inertia)
53
- // If damping is enabled, you must call controls.update() in your animation loop
54
- this.enableDamping = false;
55
- this.dampingFactor = 0.05;
56
-
57
- // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
58
- // Set to false to disable zooming
59
- this.enableZoom = true;
60
- this.zoomSpeed = 1.0;
61
-
62
- // Set to false to disable rotating
63
- this.enableRotate = true;
64
- this.rotateSpeed = 1.0;
65
-
66
- // Set to false to disable panning
67
- this.enablePan = true;
68
- this.panSpeed = 1.0;
69
- this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up
70
- this.keyPanSpeed = 7.0; // pixels moved per arrow key push
71
-
72
- // Set to true to automatically rotate around the target
73
- // If auto-rotate is enabled, you must call controls.update() in your animation loop
74
- this.autoRotate = false;
75
- this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60
76
-
77
- // The four arrow keys
78
- this.keys = {
79
- LEFT: 'ArrowLeft',
80
- UP: 'ArrowUp',
81
- RIGHT: 'ArrowRight',
82
- BOTTOM: 'ArrowDown'
83
- };
84
-
85
- // Mouse buttons
86
- this.mouseButtons = {
87
- LEFT: THREE.MOUSE.ROTATE,
88
- MIDDLE: THREE.MOUSE.DOLLY,
89
- RIGHT: THREE.MOUSE.PAN
90
- };
91
-
92
- // Touch fingers
93
- this.touches = {
94
- ONE: THREE.TOUCH.ROTATE,
95
- TWO: THREE.TOUCH.DOLLY_PAN
96
- };
97
-
98
- // for reset
99
- this.target0 = this.target.clone();
100
- this.position0 = this.object.position.clone();
101
- this.zoom0 = this.object.zoom;
102
-
103
- // the target DOM element for key events
104
- this._domElementKeyEvents = null;
105
-
106
- //
107
- // public methods
108
- //
109
-
110
- this.getPolarAngle = function () {
111
-
112
- return spherical.phi;
113
-
114
- };
115
-
116
- this.getAzimuthalAngle = function () {
117
-
118
- return spherical.theta;
119
-
120
- };
121
-
122
- this.getDistance = function () {
123
-
124
- return this.object.position.distanceTo( this.target );
125
-
126
- };
127
-
128
- this.listenToKeyEvents = function ( domElement ) {
129
-
130
- domElement.addEventListener( 'keydown', onKeyDown );
131
- this._domElementKeyEvents = domElement;
132
-
133
- };
134
-
135
- this.saveState = function () {
136
-
137
- scope.target0.copy( scope.target );
138
- scope.position0.copy( scope.object.position );
139
- scope.zoom0 = scope.object.zoom;
140
-
141
- };
142
-
143
- this.reset = function () {
144
-
145
- scope.target.copy( scope.target0 );
146
- scope.object.position.copy( scope.position0 );
147
- scope.object.zoom = scope.zoom0;
148
- scope.object.updateProjectionMatrix();
149
- scope.dispatchEvent( _changeEvent );
150
- scope.update();
151
- state = STATE.NONE;
152
-
153
- };
154
-
155
- // this method is exposed, but perhaps it would be better if we can make it private...
156
- this.update = function () {
157
-
158
- const offset = new THREE.Vector3();
159
-
160
- // so camera.up is the orbit axis
161
- const quat = new THREE.Quaternion().setFromUnitVectors( object.up, new THREE.Vector3( 0, 1, 0 ) );
162
- const quatInverse = quat.clone().invert();
163
- const lastPosition = new THREE.Vector3();
164
- const lastQuaternion = new THREE.Quaternion();
165
- const twoPI = 2 * Math.PI;
166
- return function update() {
167
-
168
- const position = scope.object.position;
169
- offset.copy( position ).sub( scope.target );
170
-
171
- // rotate offset to "y-axis-is-up" space
172
- offset.applyQuaternion( quat );
173
-
174
- // angle from z-axis around y-axis
175
- spherical.setFromVector3( offset );
176
- if ( scope.autoRotate && state === STATE.NONE ) {
177
-
178
- rotateLeft( getAutoRotationAngle() );
179
-
180
- }
181
-
182
- if ( scope.enableDamping ) {
183
-
184
- spherical.theta += sphericalDelta.theta * scope.dampingFactor;
185
- spherical.phi += sphericalDelta.phi * scope.dampingFactor;
186
-
187
- } else {
188
-
189
- spherical.theta += sphericalDelta.theta;
190
- spherical.phi += sphericalDelta.phi;
191
-
192
- }
193
-
194
- // restrict theta to be between desired limits
195
-
196
- let min = scope.minAzimuthAngle;
197
- let max = scope.maxAzimuthAngle;
198
- if ( isFinite( min ) && isFinite( max ) ) {
199
-
200
- if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI;
201
- if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI;
202
- if ( min <= max ) {
203
-
204
- spherical.theta = Math.max( min, Math.min( max, spherical.theta ) );
205
-
206
- } else {
207
-
208
- spherical.theta = spherical.theta > ( min + max ) / 2 ? Math.max( min, spherical.theta ) : Math.min( max, spherical.theta );
209
-
210
- }
211
-
212
- }
213
-
214
- // restrict phi to be between desired limits
215
- spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) );
216
- spherical.makeSafe();
217
- spherical.radius *= scale;
218
-
219
- // restrict radius to be between desired limits
220
- spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) );
221
-
222
- // move target to panned location
223
-
224
- if ( scope.enableDamping === true ) {
225
-
226
- scope.target.addScaledVector( panOffset, scope.dampingFactor );
227
-
228
- } else {
229
-
230
- scope.target.add( panOffset );
231
-
232
- }
233
-
234
- offset.setFromSpherical( spherical );
235
-
236
- // rotate offset back to "camera-up-vector-is-up" space
237
- offset.applyQuaternion( quatInverse );
238
- position.copy( scope.target ).add( offset );
239
- scope.object.lookAt( scope.target );
240
- if ( scope.enableDamping === true ) {
241
-
242
- sphericalDelta.theta *= 1 - scope.dampingFactor;
243
- sphericalDelta.phi *= 1 - scope.dampingFactor;
244
- panOffset.multiplyScalar( 1 - scope.dampingFactor );
245
-
246
- } else {
247
-
248
- sphericalDelta.set( 0, 0, 0 );
249
- panOffset.set( 0, 0, 0 );
250
-
251
- }
252
-
253
- scale = 1;
254
-
255
- // update condition is:
256
- // min(camera displacement, camera rotation in radians)^2 > EPS
257
- // using small-angle approximation cos(x/2) = 1 - x^2 / 8
258
-
259
- if ( zoomChanged || lastPosition.distanceToSquared( scope.object.position ) > EPS || 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) {
260
-
261
- scope.dispatchEvent( _changeEvent );
262
- lastPosition.copy( scope.object.position );
263
- lastQuaternion.copy( scope.object.quaternion );
264
- zoomChanged = false;
265
- return true;
266
-
267
- }
268
-
269
- return false;
270
-
271
- };
272
-
273
- }();
274
- this.dispose = function () {
275
-
276
- scope.domElement.removeEventListener( 'contextmenu', onContextMenu );
277
- scope.domElement.removeEventListener( 'pointerdown', onPointerDown );
278
- scope.domElement.removeEventListener( 'pointercancel', onPointerCancel );
279
- scope.domElement.removeEventListener( 'wheel', onMouseWheel );
280
- scope.domElement.removeEventListener( 'pointermove', onPointerMove );
281
- scope.domElement.removeEventListener( 'pointerup', onPointerUp );
282
- if ( scope._domElementKeyEvents !== null ) {
283
-
284
- scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown );
285
-
286
- }
287
-
288
- //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
289
-
290
- };
291
-
292
- //
293
- // internals
294
- //
295
-
296
- const scope = this;
297
- const STATE = {
298
- NONE: - 1,
299
- ROTATE: 0,
300
- DOLLY: 1,
301
- PAN: 2,
302
- TOUCH_ROTATE: 3,
303
- TOUCH_PAN: 4,
304
- TOUCH_DOLLY_PAN: 5,
305
- TOUCH_DOLLY_ROTATE: 6
306
- };
307
- let state = STATE.NONE;
308
- const EPS = 0.000001;
309
-
310
- // current position in spherical coordinates
311
- const spherical = new THREE.Spherical();
312
- const sphericalDelta = new THREE.Spherical();
313
- let scale = 1;
314
- const panOffset = new THREE.Vector3();
315
- let zoomChanged = false;
316
- const rotateStart = new THREE.Vector2();
317
- const rotateEnd = new THREE.Vector2();
318
- const rotateDelta = new THREE.Vector2();
319
- const panStart = new THREE.Vector2();
320
- const panEnd = new THREE.Vector2();
321
- const panDelta = new THREE.Vector2();
322
- const dollyStart = new THREE.Vector2();
323
- const dollyEnd = new THREE.Vector2();
324
- const dollyDelta = new THREE.Vector2();
325
- const pointers = [];
326
- const pointerPositions = {};
327
- function getAutoRotationAngle() {
328
-
329
- return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed;
330
-
331
- }
332
-
333
- function getZoomScale() {
334
-
335
- return Math.pow( 0.95, scope.zoomSpeed );
336
-
337
- }
338
-
339
- function rotateLeft( angle ) {
340
-
341
- sphericalDelta.theta -= angle;
342
-
343
- }
344
-
345
- function rotateUp( angle ) {
346
-
347
- sphericalDelta.phi -= angle;
348
-
349
- }
350
-
351
- const panLeft = function () {
352
-
353
- const v = new THREE.Vector3();
354
- return function panLeft( distance, objectMatrix ) {
355
-
356
- v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
357
- v.multiplyScalar( - distance );
358
- panOffset.add( v );
359
-
360
- };
361
-
362
- }();
363
- const panUp = function () {
364
-
365
- const v = new THREE.Vector3();
366
- return function panUp( distance, objectMatrix ) {
367
-
368
- if ( scope.screenSpacePanning === true ) {
369
-
370
- v.setFromMatrixColumn( objectMatrix, 1 );
371
-
372
- } else {
373
-
374
- v.setFromMatrixColumn( objectMatrix, 0 );
375
- v.crossVectors( scope.object.up, v );
376
-
377
- }
378
-
379
- v.multiplyScalar( distance );
380
- panOffset.add( v );
381
-
382
- };
383
-
384
- }();
385
-
386
- // deltaX and deltaY are in pixels; right and down are positive
387
- const pan = function () {
388
-
389
- const offset = new THREE.Vector3();
390
- return function pan( deltaX, deltaY ) {
391
-
392
- const element = scope.domElement;
393
- if ( scope.object.isPerspectiveCamera ) {
394
-
395
- // perspective
396
- const position = scope.object.position;
397
- offset.copy( position ).sub( scope.target );
398
- let targetDistance = offset.length();
399
-
400
- // half of the fov is center to top of screen
401
- targetDistance *= Math.tan( scope.object.fov / 2 * Math.PI / 180.0 );
402
-
403
- // we use only clientHeight here so aspect ratio does not distort speed
404
- panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix );
405
- panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix );
406
-
407
- } else if ( scope.object.isOrthographicCamera ) {
408
-
409
- // orthographic
410
- panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix );
411
- panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix );
412
-
413
- } else {
414
-
415
- // camera neither orthographic nor perspective
416
- console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
417
- scope.enablePan = false;
418
-
419
- }
420
-
421
- };
422
-
423
- }();
424
- function dollyOut( dollyScale ) {
425
-
426
- if ( scope.object.isPerspectiveCamera ) {
427
-
428
- scale /= dollyScale;
429
-
430
- } else if ( scope.object.isOrthographicCamera ) {
431
-
432
- scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
433
- scope.object.updateProjectionMatrix();
434
- zoomChanged = true;
435
-
436
- } else {
437
-
438
- console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
439
- scope.enableZoom = false;
440
-
441
- }
442
-
443
- }
444
-
445
- function dollyIn( dollyScale ) {
446
-
447
- if ( scope.object.isPerspectiveCamera ) {
448
-
449
- scale *= dollyScale;
450
-
451
- } else if ( scope.object.isOrthographicCamera ) {
452
-
453
- scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
454
- scope.object.updateProjectionMatrix();
455
- zoomChanged = true;
456
-
457
- } else {
458
-
459
- console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
460
- scope.enableZoom = false;
461
-
462
- }
463
-
464
- }
465
-
466
- //
467
- // event callbacks - update the object state
468
- //
469
-
470
- function handleMouseDownRotate( event ) {
471
-
472
- rotateStart.set( event.clientX, event.clientY );
473
-
474
- }
475
-
476
- function handleMouseDownDolly( event ) {
477
-
478
- dollyStart.set( event.clientX, event.clientY );
479
-
480
- }
481
-
482
- function handleMouseDownPan( event ) {
483
-
484
- panStart.set( event.clientX, event.clientY );
485
-
486
- }
487
-
488
- function handleMouseMoveRotate( event ) {
489
-
490
- rotateEnd.set( event.clientX, event.clientY );
491
- rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
492
- const element = scope.domElement;
493
- rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
494
-
495
- rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
496
- rotateStart.copy( rotateEnd );
497
- scope.update();
498
-
499
- }
500
-
501
- function handleMouseMoveDolly( event ) {
502
-
503
- dollyEnd.set( event.clientX, event.clientY );
504
- dollyDelta.subVectors( dollyEnd, dollyStart );
505
- if ( dollyDelta.y > 0 ) {
506
-
507
- dollyOut( getZoomScale() );
508
-
509
- } else if ( dollyDelta.y < 0 ) {
510
-
511
- dollyIn( getZoomScale() );
512
-
513
- }
514
-
515
- dollyStart.copy( dollyEnd );
516
- scope.update();
517
-
518
- }
519
-
520
- function handleMouseMovePan( event ) {
521
-
522
- panEnd.set( event.clientX, event.clientY );
523
- panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
524
- pan( panDelta.x, panDelta.y );
525
- panStart.copy( panEnd );
526
- scope.update();
527
-
528
- }
529
-
530
- function handleMouseWheel( event ) {
531
-
532
- if ( event.deltaY < 0 ) {
533
-
534
- dollyIn( getZoomScale() );
535
-
536
- } else if ( event.deltaY > 0 ) {
537
-
538
- dollyOut( getZoomScale() );
539
-
540
- }
541
-
542
- scope.update();
543
-
544
- }
545
-
546
- function handleKeyDown( event ) {
547
-
548
- let needsUpdate = false;
549
- switch ( event.code ) {
550
-
551
- case scope.keys.UP:
552
- if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
553
-
554
- rotateUp( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
555
-
556
- } else {
557
-
558
- pan( 0, scope.keyPanSpeed );
559
-
560
- }
561
-
562
- needsUpdate = true;
563
- break;
564
- case scope.keys.BOTTOM:
565
- if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
566
-
567
- rotateUp( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
568
-
569
- } else {
570
-
571
- pan( 0, - scope.keyPanSpeed );
572
-
573
- }
574
-
575
- needsUpdate = true;
576
- break;
577
- case scope.keys.LEFT:
578
- if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
579
-
580
- rotateLeft( 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
581
-
582
- } else {
583
-
584
- pan( scope.keyPanSpeed, 0 );
585
-
586
- }
587
-
588
- needsUpdate = true;
589
- break;
590
- case scope.keys.RIGHT:
591
- if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
592
-
593
- rotateLeft( - 2 * Math.PI * scope.rotateSpeed / scope.domElement.clientHeight );
594
-
595
- } else {
596
-
597
- pan( - scope.keyPanSpeed, 0 );
598
-
599
- }
600
-
601
- needsUpdate = true;
602
- break;
603
-
604
- }
605
-
606
- if ( needsUpdate ) {
607
-
608
- // prevent the browser from scrolling on cursor keys
609
- event.preventDefault();
610
- scope.update();
611
-
612
- }
613
-
614
- }
615
-
616
- function handleTouchStartRotate() {
617
-
618
- if ( pointers.length === 1 ) {
619
-
620
- rotateStart.set( pointers[ 0 ].pageX, pointers[ 0 ].pageY );
621
-
622
- } else {
623
-
624
- const x = 0.5 * ( pointers[ 0 ].pageX + pointers[ 1 ].pageX );
625
- const y = 0.5 * ( pointers[ 0 ].pageY + pointers[ 1 ].pageY );
626
- rotateStart.set( x, y );
627
-
628
- }
629
-
630
- }
631
-
632
- function handleTouchStartPan() {
633
-
634
- if ( pointers.length === 1 ) {
635
-
636
- panStart.set( pointers[ 0 ].pageX, pointers[ 0 ].pageY );
637
-
638
- } else {
639
-
640
- const x = 0.5 * ( pointers[ 0 ].pageX + pointers[ 1 ].pageX );
641
- const y = 0.5 * ( pointers[ 0 ].pageY + pointers[ 1 ].pageY );
642
- panStart.set( x, y );
643
-
644
- }
645
-
646
- }
647
-
648
- function handleTouchStartDolly() {
649
-
650
- const dx = pointers[ 0 ].pageX - pointers[ 1 ].pageX;
651
- const dy = pointers[ 0 ].pageY - pointers[ 1 ].pageY;
652
- const distance = Math.sqrt( dx * dx + dy * dy );
653
- dollyStart.set( 0, distance );
654
-
655
- }
656
-
657
- function handleTouchStartDollyPan() {
658
-
659
- if ( scope.enableZoom ) handleTouchStartDolly();
660
- if ( scope.enablePan ) handleTouchStartPan();
661
-
662
- }
663
-
664
- function handleTouchStartDollyRotate() {
665
-
666
- if ( scope.enableZoom ) handleTouchStartDolly();
667
- if ( scope.enableRotate ) handleTouchStartRotate();
668
-
669
- }
670
-
671
- function handleTouchMoveRotate( event ) {
672
-
673
- if ( pointers.length == 1 ) {
674
-
675
- rotateEnd.set( event.pageX, event.pageY );
676
-
677
- } else {
678
-
679
- const position = getSecondPointerPosition( event );
680
- const x = 0.5 * ( event.pageX + position.x );
681
- const y = 0.5 * ( event.pageY + position.y );
682
- rotateEnd.set( x, y );
683
-
684
- }
685
-
686
- rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
687
- const element = scope.domElement;
688
- rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
689
-
690
- rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
691
- rotateStart.copy( rotateEnd );
692
-
693
- }
694
-
695
- function handleTouchMovePan( event ) {
696
-
697
- if ( pointers.length === 1 ) {
698
-
699
- panEnd.set( event.pageX, event.pageY );
700
-
701
- } else {
702
-
703
- const position = getSecondPointerPosition( event );
704
- const x = 0.5 * ( event.pageX + position.x );
705
- const y = 0.5 * ( event.pageY + position.y );
706
- panEnd.set( x, y );
707
-
708
- }
709
-
710
- panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
711
- pan( panDelta.x, panDelta.y );
712
- panStart.copy( panEnd );
713
-
714
- }
715
-
716
- function handleTouchMoveDolly( event ) {
717
-
718
- const position = getSecondPointerPosition( event );
719
- const dx = event.pageX - position.x;
720
- const dy = event.pageY - position.y;
721
- const distance = Math.sqrt( dx * dx + dy * dy );
722
- dollyEnd.set( 0, distance );
723
- dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) );
724
- dollyOut( dollyDelta.y );
725
- dollyStart.copy( dollyEnd );
726
-
727
- }
728
-
729
- function handleTouchMoveDollyPan( event ) {
730
-
731
- if ( scope.enableZoom ) handleTouchMoveDolly( event );
732
- if ( scope.enablePan ) handleTouchMovePan( event );
733
-
734
- }
735
-
736
- function handleTouchMoveDollyRotate( event ) {
737
-
738
- if ( scope.enableZoom ) handleTouchMoveDolly( event );
739
- if ( scope.enableRotate ) handleTouchMoveRotate( event );
740
-
741
- }
742
-
743
- //
744
- // event handlers - FSM: listen for events and reset state
745
- //
746
-
747
- function onPointerDown( event ) {
748
-
749
- if ( scope.enabled === false ) return;
750
- if ( pointers.length === 0 ) {
751
-
752
- scope.domElement.setPointerCapture( event.pointerId );
753
- scope.domElement.addEventListener( 'pointermove', onPointerMove );
754
- scope.domElement.addEventListener( 'pointerup', onPointerUp );
755
-
756
- }
757
-
758
- //
759
-
760
- addPointer( event );
761
- if ( event.pointerType === 'touch' ) {
762
-
763
- onTouchStart( event );
764
-
765
- } else {
766
-
767
- onMouseDown( event );
768
-
769
- }
770
-
771
- }
772
-
773
- function onPointerMove( event ) {
774
-
775
- if ( scope.enabled === false ) return;
776
- if ( event.pointerType === 'touch' ) {
777
-
778
- onTouchMove( event );
779
-
780
- } else {
781
-
782
- onMouseMove( event );
783
-
784
- }
785
-
786
- }
787
-
788
- function onPointerUp( event ) {
789
-
790
- removePointer( event );
791
- if ( pointers.length === 0 ) {
792
-
793
- scope.domElement.releasePointerCapture( event.pointerId );
794
- scope.domElement.removeEventListener( 'pointermove', onPointerMove );
795
- scope.domElement.removeEventListener( 'pointerup', onPointerUp );
796
-
797
- }
798
-
799
- scope.dispatchEvent( _endEvent );
800
- state = STATE.NONE;
801
-
802
- }
803
-
804
- function onPointerCancel( event ) {
805
-
806
- removePointer( event );
807
-
808
- }
809
-
810
- function onMouseDown( event ) {
811
-
812
- let mouseAction;
813
- switch ( event.button ) {
814
-
815
- case 0:
816
- mouseAction = scope.mouseButtons.LEFT;
817
- break;
818
- case 1:
819
- mouseAction = scope.mouseButtons.MIDDLE;
820
- break;
821
- case 2:
822
- mouseAction = scope.mouseButtons.RIGHT;
823
- break;
824
- default:
825
- mouseAction = - 1;
826
-
827
- }
828
-
829
- switch ( mouseAction ) {
830
-
831
- case THREE.MOUSE.DOLLY:
832
- if ( scope.enableZoom === false ) return;
833
- handleMouseDownDolly( event );
834
- state = STATE.DOLLY;
835
- break;
836
- case THREE.MOUSE.ROTATE:
837
- if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
838
-
839
- if ( scope.enablePan === false ) return;
840
- handleMouseDownPan( event );
841
- state = STATE.PAN;
842
-
843
- } else {
844
-
845
- if ( scope.enableRotate === false ) return;
846
- handleMouseDownRotate( event );
847
- state = STATE.ROTATE;
848
-
849
- }
850
-
851
- break;
852
- case THREE.MOUSE.PAN:
853
- if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
854
-
855
- if ( scope.enableRotate === false ) return;
856
- handleMouseDownRotate( event );
857
- state = STATE.ROTATE;
858
-
859
- } else {
860
-
861
- if ( scope.enablePan === false ) return;
862
- handleMouseDownPan( event );
863
- state = STATE.PAN;
864
-
865
- }
866
-
867
- break;
868
- default:
869
- state = STATE.NONE;
870
-
871
- }
872
-
873
- if ( state !== STATE.NONE ) {
874
-
875
- scope.dispatchEvent( _startEvent );
876
-
877
- }
878
-
879
- }
880
-
881
- function onMouseMove( event ) {
882
-
883
- switch ( state ) {
884
-
885
- case STATE.ROTATE:
886
- if ( scope.enableRotate === false ) return;
887
- handleMouseMoveRotate( event );
888
- break;
889
- case STATE.DOLLY:
890
- if ( scope.enableZoom === false ) return;
891
- handleMouseMoveDolly( event );
892
- break;
893
- case STATE.PAN:
894
- if ( scope.enablePan === false ) return;
895
- handleMouseMovePan( event );
896
- break;
897
-
898
- }
899
-
900
- }
901
-
902
- function onMouseWheel( event ) {
903
-
904
- if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE ) return;
905
- event.preventDefault();
906
- scope.dispatchEvent( _startEvent );
907
- handleMouseWheel( event );
908
- scope.dispatchEvent( _endEvent );
909
-
910
- }
911
-
912
- function onKeyDown( event ) {
913
-
914
- if ( scope.enabled === false || scope.enablePan === false ) return;
915
- handleKeyDown( event );
916
-
917
- }
918
-
919
- function onTouchStart( event ) {
920
-
921
- trackPointer( event );
922
- switch ( pointers.length ) {
923
-
924
- case 1:
925
- switch ( scope.touches.ONE ) {
926
-
927
- case THREE.TOUCH.ROTATE:
928
- if ( scope.enableRotate === false ) return;
929
- handleTouchStartRotate();
930
- state = STATE.TOUCH_ROTATE;
931
- break;
932
- case THREE.TOUCH.PAN:
933
- if ( scope.enablePan === false ) return;
934
- handleTouchStartPan();
935
- state = STATE.TOUCH_PAN;
936
- break;
937
- default:
938
- state = STATE.NONE;
939
-
940
- }
941
-
942
- break;
943
- case 2:
944
- switch ( scope.touches.TWO ) {
945
-
946
- case THREE.TOUCH.DOLLY_PAN:
947
- if ( scope.enableZoom === false && scope.enablePan === false ) return;
948
- handleTouchStartDollyPan();
949
- state = STATE.TOUCH_DOLLY_PAN;
950
- break;
951
- case THREE.TOUCH.DOLLY_ROTATE:
952
- if ( scope.enableZoom === false && scope.enableRotate === false ) return;
953
- handleTouchStartDollyRotate();
954
- state = STATE.TOUCH_DOLLY_ROTATE;
955
- break;
956
- default:
957
- state = STATE.NONE;
958
-
959
- }
960
-
961
- break;
962
- default:
963
- state = STATE.NONE;
964
-
965
- }
966
-
967
- if ( state !== STATE.NONE ) {
968
-
969
- scope.dispatchEvent( _startEvent );
970
-
971
- }
972
-
973
- }
974
-
975
- function onTouchMove( event ) {
976
-
977
- trackPointer( event );
978
- switch ( state ) {
979
-
980
- case STATE.TOUCH_ROTATE:
981
- if ( scope.enableRotate === false ) return;
982
- handleTouchMoveRotate( event );
983
- scope.update();
984
- break;
985
- case STATE.TOUCH_PAN:
986
- if ( scope.enablePan === false ) return;
987
- handleTouchMovePan( event );
988
- scope.update();
989
- break;
990
- case STATE.TOUCH_DOLLY_PAN:
991
- if ( scope.enableZoom === false && scope.enablePan === false ) return;
992
- handleTouchMoveDollyPan( event );
993
- scope.update();
994
- break;
995
- case STATE.TOUCH_DOLLY_ROTATE:
996
- if ( scope.enableZoom === false && scope.enableRotate === false ) return;
997
- handleTouchMoveDollyRotate( event );
998
- scope.update();
999
- break;
1000
- default:
1001
- state = STATE.NONE;
1002
-
1003
- }
1004
-
1005
- }
1006
-
1007
- function onContextMenu( event ) {
1008
-
1009
- if ( scope.enabled === false ) return;
1010
- event.preventDefault();
1011
-
1012
- }
1013
-
1014
- function addPointer( event ) {
1015
-
1016
- pointers.push( event );
1017
-
1018
- }
1019
-
1020
- function removePointer( event ) {
1021
-
1022
- delete pointerPositions[ event.pointerId ];
1023
- for ( let i = 0; i < pointers.length; i ++ ) {
1024
-
1025
- if ( pointers[ i ].pointerId == event.pointerId ) {
1026
-
1027
- pointers.splice( i, 1 );
1028
- return;
1029
-
1030
- }
1031
-
1032
- }
1033
-
1034
- }
1035
-
1036
- function trackPointer( event ) {
1037
-
1038
- let position = pointerPositions[ event.pointerId ];
1039
- if ( position === undefined ) {
1040
-
1041
- position = new THREE.Vector2();
1042
- pointerPositions[ event.pointerId ] = position;
1043
-
1044
- }
1045
-
1046
- position.set( event.pageX, event.pageY );
1047
-
1048
- }
1049
-
1050
- function getSecondPointerPosition( event ) {
1051
-
1052
- const pointer = event.pointerId === pointers[ 0 ].pointerId ? pointers[ 1 ] : pointers[ 0 ];
1053
- return pointerPositions[ pointer.pointerId ];
1054
-
1055
- }
1056
-
1057
- //
1058
-
1059
- scope.domElement.addEventListener( 'contextmenu', onContextMenu );
1060
- scope.domElement.addEventListener( 'pointerdown', onPointerDown );
1061
- scope.domElement.addEventListener( 'pointercancel', onPointerCancel );
1062
- scope.domElement.addEventListener( 'wheel', onMouseWheel, {
1063
- passive: false
1064
- } );
1065
-
1066
- // force an update at start
1067
-
1068
- this.update();
1069
-
1070
- }
1071
-
1072
- }
1073
-
1074
- // This set of controls performs orbiting, dollying (zooming), and panning.
1075
- // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
1076
- // This is very similar to OrbitControls, another set of touch behavior
1077
- //
1078
- // Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate
1079
- // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
1080
- // Pan - left mouse, or arrow keys / touch: one-finger move
1081
-
1082
- class MapControls extends OrbitControls {
1083
-
1084
- constructor( object, domElement ) {
1085
-
1086
- super( object, domElement );
1087
- this.screenSpacePanning = false; // pan orthogonal to world-space direction camera.up
1088
-
1089
- this.mouseButtons.LEFT = THREE.MOUSE.PAN;
1090
- this.mouseButtons.RIGHT = THREE.MOUSE.ROTATE;
1091
- this.touches.ONE = THREE.TOUCH.PAN;
1092
- this.touches.TWO = THREE.TOUCH.DOLLY_ROTATE;
1093
-
1094
- }
1095
-
1096
- }
1097
-
1098
- THREE.MapControls = MapControls;
1099
- THREE.OrbitControls = OrbitControls;
1100
-
1101
- } )();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/vendor/d3-hierarchy.v3.min.js DELETED
@@ -1,2 +0,0 @@
1
- // https://d3js.org/d3-hierarchy/ v3.1.2 Copyright 2010-2021 Mike Bostock
2
- !function(n,r){"object"==typeof exports&&"undefined"!=typeof module?r(exports):"function"==typeof define&&define.amd?define(["exports"],r):r((n="undefined"!=typeof globalThis?globalThis:n||self).d3=n.d3||{})}(this,(function(n){"use strict";function r(n,r){return n.parent===r.parent?1:2}function t(n,r){return n+r.x}function e(n,r){return Math.max(n,r.y)}function i(n){var r=0,t=n.children,e=t&&t.length;if(e)for(;--e>=0;)r+=t[e].value;else r=1;n.value=r}function u(n,r){n instanceof Map?(n=[void 0,n],void 0===r&&(r=f)):void 0===r&&(r=o);for(var t,e,i,u,a,l=new h(n),p=[l];t=p.pop();)if((i=r(t.data))&&(a=(i=Array.from(i)).length))for(t.children=i,u=a-1;u>=0;--u)p.push(e=i[u]=new h(i[u])),e.parent=t,e.depth=t.depth+1;return l.eachBefore(c)}function o(n){return n.children}function f(n){return Array.isArray(n)?n[1]:null}function a(n){void 0!==n.data.value&&(n.value=n.data.value),n.data=n.data.data}function c(n){var r=0;do{n.height=r}while((n=n.parent)&&n.height<++r)}function h(n){this.data=n,this.depth=this.height=0,this.parent=null}function l(n){return null==n?null:p(n)}function p(n){if("function"!=typeof n)throw new Error;return n}function d(){return 0}function s(n){return function(){return n}}h.prototype=u.prototype={constructor:h,count:function(){return this.eachAfter(i)},each:function(n,r){let t=-1;for(const e of this)n.call(r,e,++t,this);return this},eachAfter:function(n,r){for(var t,e,i,u=this,o=[u],f=[],a=-1;u=o.pop();)if(f.push(u),t=u.children)for(e=0,i=t.length;e<i;++e)o.push(t[e]);for(;u=f.pop();)n.call(r,u,++a,this);return this},eachBefore:function(n,r){for(var t,e,i=this,u=[i],o=-1;i=u.pop();)if(n.call(r,i,++o,this),t=i.children)for(e=t.length-1;e>=0;--e)u.push(t[e]);return this},find:function(n,r){let t=-1;for(const e of this)if(n.call(r,e,++t,this))return e},sum:function(n){return this.eachAfter((function(r){for(var t=+n(r.data)||0,e=r.children,i=e&&e.length;--i>=0;)t+=e[i].value;r.value=t}))},sort:function(n){return this.eachBefore((function(r){r.children&&r.children.sort(n)}))},path:function(n){for(var r=this,t=function(n,r){if(n===r)return n;var t=n.ancestors(),e=r.ancestors(),i=null;n=t.pop(),r=e.pop();for(;n===r;)i=n,n=t.pop(),r=e.pop();return i}(r,n),e=[r];r!==t;)r=r.parent,e.push(r);for(var i=e.length;n!==t;)e.splice(i,0,n),n=n.parent;return e},ancestors:function(){for(var n=this,r=[n];n=n.parent;)r.push(n);return r},descendants:function(){return Array.from(this)},leaves:function(){var n=[];return this.eachBefore((function(r){r.children||n.push(r)})),n},links:function(){var n=this,r=[];return n.each((function(t){t!==n&&r.push({source:t.parent,target:t})})),r},copy:function(){return u(this).eachBefore(a)},[Symbol.iterator]:function*(){var n,r,t,e,i=this,u=[i];do{for(n=u.reverse(),u=[];i=n.pop();)if(yield i,r=i.children)for(t=0,e=r.length;t<e;++t)u.push(r[t])}while(u.length)}};const v=4294967296;function x(){let n=1;return()=>(n=(1664525*n+1013904223)%v)/v}function y(n,r){for(var t,e,i=0,u=(n=function(n,r){let t,e,i=n.length;for(;i;)e=r()*i--|0,t=n[i],n[i]=n[e],n[e]=t;return n}(Array.from(n),r)).length,o=[];i<u;)t=n[i],e&&w(e,t)?++i:(e=M(o=g(o,t)),i=0);return e}function g(n,r){var t,e;if(_(r,n))return[r];for(t=0;t<n.length;++t)if(m(r,n[t])&&_(z(n[t],r),n))return[n[t],r];for(t=0;t<n.length-1;++t)for(e=t+1;e<n.length;++e)if(m(z(n[t],n[e]),r)&&m(z(n[t],r),n[e])&&m(z(n[e],r),n[t])&&_(B(n[t],n[e],r),n))return[n[t],n[e],r];throw new Error}function m(n,r){var t=n.r-r.r,e=r.x-n.x,i=r.y-n.y;return t<0||t*t<e*e+i*i}function w(n,r){var t=n.r-r.r+1e-9*Math.max(n.r,r.r,1),e=r.x-n.x,i=r.y-n.y;return t>0&&t*t>e*e+i*i}function _(n,r){for(var t=0;t<r.length;++t)if(!w(n,r[t]))return!1;return!0}function M(n){switch(n.length){case 1:return function(n){return{x:n.x,y:n.y,r:n.r}}(n[0]);case 2:return z(n[0],n[1]);case 3:return B(n[0],n[1],n[2])}}function z(n,r){var t=n.x,e=n.y,i=n.r,u=r.x,o=r.y,f=r.r,a=u-t,c=o-e,h=f-i,l=Math.sqrt(a*a+c*c);return{x:(t+u+a/l*h)/2,y:(e+o+c/l*h)/2,r:(l+i+f)/2}}function B(n,r,t){var e=n.x,i=n.y,u=n.r,o=r.x,f=r.y,a=r.r,c=t.x,h=t.y,l=t.r,p=e-o,d=e-c,s=i-f,v=i-h,x=a-u,y=l-u,g=e*e+i*i-u*u,m=g-o*o-f*f+a*a,w=g-c*c-h*h+l*l,_=d*s-p*v,M=(s*w-v*m)/(2*_)-e,z=(v*x-s*y)/_,B=(d*m-p*w)/(2*_)-i,A=(p*y-d*x)/_,b=z*z+A*A-1,q=2*(u+M*z+B*A),E=M*M+B*B-u*u,S=-(Math.abs(b)>1e-6?(q+Math.sqrt(q*q-4*b*E))/(2*b):E/q);return{x:e+M+z*S,y:i+B+A*S,r:S}}function A(n,r,t){var e,i,u,o,f=n.x-r.x,a=n.y-r.y,c=f*f+a*a;c?(i=r.r+t.r,i*=i,o=n.r+t.r,i>(o*=o)?(e=(c+o-i)/(2*c),u=Math.sqrt(Math.max(0,o/c-e*e)),t.x=n.x-e*f-u*a,t.y=n.y-e*a+u*f):(e=(c+i-o)/(2*c),u=Math.sqrt(Math.max(0,i/c-e*e)),t.x=r.x+e*f-u*a,t.y=r.y+e*a+u*f)):(t.x=r.x+t.r,t.y=r.y)}function b(n,r){var t=n.r+r.r-1e-6,e=r.x-n.x,i=r.y-n.y;return t>0&&t*t>e*e+i*i}function q(n){var r=n._,t=n.next._,e=r.r+t.r,i=(r.x*t.r+t.x*r.r)/e,u=(r.y*t.r+t.y*r.r)/e;return i*i+u*u}function Node(n){this._=n,this.next=null,this.previous=null}function E(n,r){if(!(o=(t=n,n="object"==typeof t&&"length"in t?t:Array.from(t)).length))return 0;var t,e,i,u,o,f,a,c,h,l,p,d;if((e=n[0]).x=0,e.y=0,!(o>1))return e.r;if(i=n[1],e.x=-i.r,i.x=e.r,i.y=0,!(o>2))return e.r+i.r;A(i,e,u=n[2]),e=new Node(e),i=new Node(i),u=new Node(u),e.next=u.previous=i,i.next=e.previous=u,u.next=i.previous=e;n:for(c=3;c<o;++c){A(e._,i._,u=n[c]),u=new Node(u),h=i.next,l=e.previous,p=i._.r,d=e._.r;do{if(p<=d){if(b(h._,u._)){i=h,e.next=i,i.previous=e,--c;continue n}p+=h._.r,h=h.next}else{if(b(l._,u._)){(e=l).next=i,i.previous=e,--c;continue n}d+=l._.r,l=l.previous}}while(h!==l.next);for(u.previous=e,u.next=i,e.next=i.previous=i=u,f=q(e);(u=u.next)!==i;)(a=q(u))<f&&(e=u,f=a);i=e.next}for(e=[i._],u=i;(u=u.next)!==i;)e.push(u._);for(u=y(e,r),c=0;c<o;++c)(e=n[c]).x-=u.x,e.y-=u.y;return u.r}function S(n){return Math.sqrt(n.value)}function k(n){return function(r){r.children||(r.r=Math.max(0,+n(r)||0))}}function I(n,r,t){return function(e){if(i=e.children){var i,u,o,f=i.length,a=n(e)*r||0;if(a)for(u=0;u<f;++u)i[u].r+=a;if(o=E(i,t),a)for(u=0;u<f;++u)i[u].r-=a;e.r=o+a}}}function T(n){return function(r){var t=r.parent;r.r*=n,t&&(r.x=t.x+n*r.x,r.y=t.y+n*r.y)}}function j(n){n.x0=Math.round(n.x0),n.y0=Math.round(n.y0),n.x1=Math.round(n.x1),n.y1=Math.round(n.y1)}function O(n,r,t,e,i){for(var u,o=n.children,f=-1,a=o.length,c=n.value&&(e-r)/n.value;++f<a;)(u=o[f]).y0=t,u.y1=i,u.x0=r,u.x1=r+=u.value*c}var R={depth:-1},D={},L={};function $(n){return n.id}function N(n){return n.parentId}function P(n){let r=n.length;if(r<2)return"";for(;--r>1&&!C(n,r););return n.slice(0,r)}function C(n,r){if("/"===n[r]){let t=0;for(;r>0&&"\\"===n[--r];)++t;if(0==(1&t))return!0}return!1}function F(n,r){return n.parent===r.parent?1:2}function G(n){var r=n.children;return r?r[0]:n.t}function H(n){var r=n.children;return r?r[r.length-1]:n.t}function J(n,r,t){var e=t/(r.i-n.i);r.c-=e,r.s+=t,n.c+=e,r.z+=t,r.m+=t}function K(n,r,t){return n.a.parent===r.parent?n.a:t}function Q(n,r){this._=n,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=r}function U(n,r,t,e,i){for(var u,o=n.children,f=-1,a=o.length,c=n.value&&(i-t)/n.value;++f<a;)(u=o[f]).x0=r,u.x1=e,u.y0=t,u.y1=t+=u.value*c}Q.prototype=Object.create(h.prototype);var V=(1+Math.sqrt(5))/2;function W(n,r,t,e,i,u){for(var o,f,a,c,h,l,p,d,s,v,x,y=[],g=r.children,m=0,w=0,_=g.length,M=r.value;m<_;){a=i-t,c=u-e;do{h=g[w++].value}while(!h&&w<_);for(l=p=h,x=h*h*(v=Math.max(c/a,a/c)/(M*n)),s=Math.max(p/x,x/l);w<_;++w){if(h+=f=g[w].value,f<l&&(l=f),f>p&&(p=f),x=h*h*v,(d=Math.max(p/x,x/l))>s){h-=f;break}s=d}y.push(o={value:h,dice:a<c,children:g.slice(m,w)}),o.dice?O(o,t,e,i,M?e+=c*h/M:u):U(o,t,e,M?t+=a*h/M:i,u),M-=h,m=w}return y}var X=function n(r){function t(n,t,e,i,u){W(r,n,t,e,i,u)}return t.ratio=function(r){return n((r=+r)>1?r:1)},t}(V);var Y=function n(r){function t(n,t,e,i,u){if((o=n._squarify)&&o.ratio===r)for(var o,f,a,c,h,l=-1,p=o.length,d=n.value;++l<p;){for(a=(f=o[l]).children,c=f.value=0,h=a.length;c<h;++c)f.value+=a[c].value;f.dice?O(f,t,e,i,d?e+=(u-e)*f.value/d:u):U(f,t,e,d?t+=(i-t)*f.value/d:i,u),d-=f.value}else n._squarify=o=W(r,n,t,e,i,u),o.ratio=r}return t.ratio=function(r){return n((r=+r)>1?r:1)},t}(V);n.Node=h,n.cluster=function(){var n=r,i=1,u=1,o=!1;function f(r){var f,a=0;r.eachAfter((function(r){var i=r.children;i?(r.x=function(n){return n.reduce(t,0)/n.length}(i),r.y=function(n){return 1+n.reduce(e,0)}(i)):(r.x=f?a+=n(r,f):0,r.y=0,f=r)}));var c=function(n){for(var r;r=n.children;)n=r[0];return n}(r),h=function(n){for(var r;r=n.children;)n=r[r.length-1];return n}(r),l=c.x-n(c,h)/2,p=h.x+n(h,c)/2;return r.eachAfter(o?function(n){n.x=(n.x-r.x)*i,n.y=(r.y-n.y)*u}:function(n){n.x=(n.x-l)/(p-l)*i,n.y=(1-(r.y?n.y/r.y:1))*u})}return f.separation=function(r){return arguments.length?(n=r,f):n},f.size=function(n){return arguments.length?(o=!1,i=+n[0],u=+n[1],f):o?null:[i,u]},f.nodeSize=function(n){return arguments.length?(o=!0,i=+n[0],u=+n[1],f):o?[i,u]:null},f},n.hierarchy=u,n.pack=function(){var n=null,r=1,t=1,e=d;function i(i){const u=x();return i.x=r/2,i.y=t/2,n?i.eachBefore(k(n)).eachAfter(I(e,.5,u)).eachBefore(T(1)):i.eachBefore(k(S)).eachAfter(I(d,1,u)).eachAfter(I(e,i.r/Math.min(r,t),u)).eachBefore(T(Math.min(r,t)/(2*i.r))),i}return i.radius=function(r){return arguments.length?(n=l(r),i):n},i.size=function(n){return arguments.length?(r=+n[0],t=+n[1],i):[r,t]},i.padding=function(n){return arguments.length?(e="function"==typeof n?n:s(+n),i):e},i},n.packEnclose=function(n){return y(n,x())},n.packSiblings=function(n){return E(n,x()),n},n.partition=function(){var n=1,r=1,t=0,e=!1;function i(i){var u=i.height+1;return i.x0=i.y0=t,i.x1=n,i.y1=r/u,i.eachBefore(function(n,r){return function(e){e.children&&O(e,e.x0,n*(e.depth+1)/r,e.x1,n*(e.depth+2)/r);var i=e.x0,u=e.y0,o=e.x1-t,f=e.y1-t;o<i&&(i=o=(i+o)/2),f<u&&(u=f=(u+f)/2),e.x0=i,e.y0=u,e.x1=o,e.y1=f}}(r,u)),e&&i.eachBefore(j),i}return i.round=function(n){return arguments.length?(e=!!n,i):e},i.size=function(t){return arguments.length?(n=+t[0],r=+t[1],i):[n,r]},i.padding=function(n){return arguments.length?(t=+n,i):t},i},n.stratify=function(){var n,r=$,t=N;function e(e){var i,u,o,f,a,l,p,d,s=Array.from(e),v=r,x=t,y=new Map;if(null!=n){const r=s.map(((r,t)=>function(n){let r=(n=`${n}`).length;C(n,r-1)&&!C(n,r-2)&&(n=n.slice(0,-1));return"/"===n[0]?n:`/${n}`}(n(r,t,e)))),t=r.map(P),i=new Set(r).add("");for(const n of t)i.has(n)||(i.add(n),r.push(n),t.push(P(n)),s.push(L));v=(n,t)=>r[t],x=(n,r)=>t[r]}for(o=0,i=s.length;o<i;++o)u=s[o],l=s[o]=new h(u),null!=(p=v(u,o,e))&&(p+="")&&(d=l.id=p,y.set(d,y.has(d)?D:l)),null!=(p=x(u,o,e))&&(p+="")&&(l.parent=p);for(o=0;o<i;++o)if(p=(l=s[o]).parent){if(!(a=y.get(p)))throw new Error("missing: "+p);if(a===D)throw new Error("ambiguous: "+p);a.children?a.children.push(l):a.children=[l],l.parent=a}else{if(f)throw new Error("multiple roots");f=l}if(!f)throw new Error("no root");if(null!=n){for(;f.data===L&&1===f.children.length;)f=f.children[0],--i;for(let n=s.length-1;n>=0&&(l=s[n],l.data===L);--n)l.data=null}if(f.parent=R,f.eachBefore((function(n){n.depth=n.parent.depth+1,--i})).eachBefore(c),f.parent=null,i>0)throw new Error("cycle");return f}return e.id=function(n){return arguments.length?(r=l(n),e):r},e.parentId=function(n){return arguments.length?(t=l(n),e):t},e.path=function(r){return arguments.length?(n=l(r),e):n},e},n.tree=function(){var n=F,r=1,t=1,e=null;function i(i){var a=function(n){for(var r,t,e,i,u,o=new Q(n,0),f=[o];r=f.pop();)if(e=r._.children)for(r.children=new Array(u=e.length),i=u-1;i>=0;--i)f.push(t=r.children[i]=new Q(e[i],i)),t.parent=r;return(o.parent=new Q(null,0)).children=[o],o}(i);if(a.eachAfter(u),a.parent.m=-a.z,a.eachBefore(o),e)i.eachBefore(f);else{var c=i,h=i,l=i;i.eachBefore((function(n){n.x<c.x&&(c=n),n.x>h.x&&(h=n),n.depth>l.depth&&(l=n)}));var p=c===h?1:n(c,h)/2,d=p-c.x,s=r/(h.x+p+d),v=t/(l.depth||1);i.eachBefore((function(n){n.x=(n.x+d)*s,n.y=n.depth*v}))}return i}function u(r){var t=r.children,e=r.parent.children,i=r.i?e[r.i-1]:null;if(t){!function(n){for(var r,t=0,e=0,i=n.children,u=i.length;--u>=0;)(r=i[u]).z+=t,r.m+=t,t+=r.s+(e+=r.c)}(r);var u=(t[0].z+t[t.length-1].z)/2;i?(r.z=i.z+n(r._,i._),r.m=r.z-u):r.z=u}else i&&(r.z=i.z+n(r._,i._));r.parent.A=function(r,t,e){if(t){for(var i,u=r,o=r,f=t,a=u.parent.children[0],c=u.m,h=o.m,l=f.m,p=a.m;f=H(f),u=G(u),f&&u;)a=G(a),(o=H(o)).a=r,(i=f.z+l-u.z-c+n(f._,u._))>0&&(J(K(f,r,e),r,i),c+=i,h+=i),l+=f.m,c+=u.m,p+=a.m,h+=o.m;f&&!H(o)&&(o.t=f,o.m+=l-h),u&&!G(a)&&(a.t=u,a.m+=c-p,e=r)}return e}(r,i,r.parent.A||e[0])}function o(n){n._.x=n.z+n.parent.m,n.m+=n.parent.m}function f(n){n.x*=r,n.y=n.depth*t}return i.separation=function(r){return arguments.length?(n=r,i):n},i.size=function(n){return arguments.length?(e=!1,r=+n[0],t=+n[1],i):e?null:[r,t]},i.nodeSize=function(n){return arguments.length?(e=!0,r=+n[0],t=+n[1],i):e?[r,t]:null},i},n.treemap=function(){var n=X,r=!1,t=1,e=1,i=[0],u=d,o=d,f=d,a=d,c=d;function h(n){return n.x0=n.y0=0,n.x1=t,n.y1=e,n.eachBefore(l),i=[0],r&&n.eachBefore(j),n}function l(r){var t=i[r.depth],e=r.x0+t,h=r.y0+t,l=r.x1-t,p=r.y1-t;l<e&&(e=l=(e+l)/2),p<h&&(h=p=(h+p)/2),r.x0=e,r.y0=h,r.x1=l,r.y1=p,r.children&&(t=i[r.depth+1]=u(r)/2,e+=c(r)-t,h+=o(r)-t,(l-=f(r)-t)<e&&(e=l=(e+l)/2),(p-=a(r)-t)<h&&(h=p=(h+p)/2),n(r,e,h,l,p))}return h.round=function(n){return arguments.length?(r=!!n,h):r},h.size=function(n){return arguments.length?(t=+n[0],e=+n[1],h):[t,e]},h.tile=function(r){return arguments.length?(n=p(r),h):n},h.padding=function(n){return arguments.length?h.paddingInner(n).paddingOuter(n):h.paddingInner()},h.paddingInner=function(n){return arguments.length?(u="function"==typeof n?n:s(+n),h):u},h.paddingOuter=function(n){return arguments.length?h.paddingTop(n).paddingRight(n).paddingBottom(n).paddingLeft(n):h.paddingTop()},h.paddingTop=function(n){return arguments.length?(o="function"==typeof n?n:s(+n),h):o},h.paddingRight=function(n){return arguments.length?(f="function"==typeof n?n:s(+n),h):f},h.paddingBottom=function(n){return arguments.length?(a="function"==typeof n?n:s(+n),h):a},h.paddingLeft=function(n){return arguments.length?(c="function"==typeof n?n:s(+n),h):c},h},n.treemapBinary=function(n,r,t,e,i){var u,o,f=n.children,a=f.length,c=new Array(a+1);for(c[0]=o=u=0;u<a;++u)c[u+1]=o+=f[u].value;!function n(r,t,e,i,u,o,a){if(r>=t-1){var h=f[r];return h.x0=i,h.y0=u,h.x1=o,void(h.y1=a)}var l=c[r],p=e/2+l,d=r+1,s=t-1;for(;d<s;){var v=d+s>>>1;c[v]<p?d=v+1:s=v}p-c[d-1]<c[d]-p&&r+1<d&&--d;var x=c[d]-l,y=e-x;if(o-i>a-u){var g=e?(i*y+o*x)/e:o;n(r,d,x,i,u,g,a),n(d,t,y,g,u,o,a)}else{var m=e?(u*y+a*x)/e:a;n(r,d,x,i,u,o,m),n(d,t,y,i,m,o,a)}}(0,a,n.value,r,t,e,i)},n.treemapDice=O,n.treemapResquarify=Y,n.treemapSlice=U,n.treemapSliceDice=function(n,r,t,e,i){(1&n.depth?U:O)(n,r,t,e,i)},n.treemapSquarify=X,Object.defineProperty(n,"__esModule",{value:!0})}));
 
 
 
frontend/vendor/d3-path.v3.min.js DELETED
@@ -1,2 +0,0 @@
1
- // https://d3js.org/d3-path/ v3.1.0 Copyright 2015-2022 Mike Bostock
2
- !function(t,i){"object"==typeof exports&&"undefined"!=typeof module?i(exports):"function"==typeof define&&define.amd?define(["exports"],i):i((t="undefined"!=typeof globalThis?globalThis:t||self).d3=t.d3||{})}(this,(function(t){"use strict";const i=Math.PI,s=2*i,h=1e-6,e=s-h;function n(t){this._+=t[0];for(let i=1,s=t.length;i<s;++i)this._+=arguments[i]+t[i]}class _{constructor(t){this._x0=this._y0=this._x1=this._y1=null,this._="",this._append=null==t?n:function(t){let i=Math.floor(t);if(!(i>=0))throw new Error(`invalid digits: ${t}`);if(i>15)return n;const s=10**i;return function(t){this._+=t[0];for(let i=1,h=t.length;i<h;++i)this._+=Math.round(arguments[i]*s)/s+t[i]}}(t)}moveTo(t,i){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+i}`}closePath(){null!==this._x1&&(this._x1=this._x0,this._y1=this._y0,this._append`Z`)}lineTo(t,i){this._append`L${this._x1=+t},${this._y1=+i}`}quadraticCurveTo(t,i,s,h){this._append`Q${+t},${+i},${this._x1=+s},${this._y1=+h}`}bezierCurveTo(t,i,s,h,e,n){this._append`C${+t},${+i},${+s},${+h},${this._x1=+e},${this._y1=+n}`}arcTo(t,s,e,n,_){if(t=+t,s=+s,e=+e,n=+n,(_=+_)<0)throw new Error(`negative radius: ${_}`);let a=this._x1,$=this._y1,o=e-t,r=n-s,p=a-t,d=$-s,l=p*p+d*d;if(null===this._x1)this._append`M${this._x1=t},${this._y1=s}`;else if(l>h)if(Math.abs(d*o-r*p)>h&&_){let u=e-a,f=n-$,x=o*o+r*r,y=u*u+f*f,c=Math.sqrt(x),M=Math.sqrt(l),b=_*Math.tan((i-Math.acos((x+l-y)/(2*c*M)))/2),g=b/M,w=b/c;Math.abs(g-1)>h&&this._append`L${t+g*p},${s+g*d}`,this._append`A${_},${_},0,0,${+(d*u>p*f)},${this._x1=t+w*o},${this._y1=s+w*r}`}else this._append`L${this._x1=t},${this._y1=s}`;else;}arc(t,n,_,a,$,o){if(t=+t,n=+n,o=!!o,(_=+_)<0)throw new Error(`negative radius: ${_}`);let r=_*Math.cos(a),p=_*Math.sin(a),d=t+r,l=n+p,u=1^o,f=o?a-$:$-a;null===this._x1?this._append`M${d},${l}`:(Math.abs(this._x1-d)>h||Math.abs(this._y1-l)>h)&&this._append`L${d},${l}`,_&&(f<0&&(f=f%s+s),f>e?this._append`A${_},${_},0,1,${u},${t-r},${n-p}A${_},${_},0,1,${u},${this._x1=d},${this._y1=l}`:f>h&&this._append`A${_},${_},0,${+(f>=i)},${u},${this._x1=t+_*Math.cos($)},${this._y1=n+_*Math.sin($)}`)}rect(t,i,s,h){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+i}h${s=+s}v${+h}h${-s}Z`}toString(){return this._}}function a(){return new _}a.prototype=_.prototype,t.Path=_,t.path=a,t.pathRound=function(t=3){return new _(+t)}}));
 
 
 
frontend/vendor/d3-shape.v3.min.js DELETED
@@ -1,2 +0,0 @@
1
- // https://d3js.org/d3-shape/ v3.2.0 Copyright 2010-2022 Mike Bostock
2
- !function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports,require("d3-path")):"function"==typeof define&&define.amd?define(["exports","d3-path"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).d3=t.d3||{},t.d3)}(this,(function(t,n){"use strict";function i(t){return function(){return t}}const e=Math.abs,s=Math.atan2,o=Math.cos,h=Math.max,_=Math.min,r=Math.sin,a=Math.sqrt,l=1e-12,c=Math.PI,u=c/2,f=2*c;function y(t){return t>1?0:t<-1?c:Math.acos(t)}function x(t){return t>=1?u:t<=-1?-u:Math.asin(t)}function p(t){let i=3;return t.digits=function(n){if(!arguments.length)return i;if(null==n)i=null;else{const t=Math.floor(n);if(!(t>=0))throw new RangeError(`invalid digits: ${n}`);i=t}return t},()=>new n.Path(i)}function v(t){return t.innerRadius}function d(t){return t.outerRadius}function T(t){return t.startAngle}function g(t){return t.endAngle}function m(t){return t&&t.padAngle}function b(t,n,i,e,s,o,h,_){var r=i-t,a=e-n,c=h-s,u=_-o,f=u*r-c*a;if(!(f*f<l))return[t+(f=(c*(n-o)-u*(t-s))/f)*r,n+f*a]}function w(t,n,i,e,s,o,_){var r=t-i,l=n-e,c=(_?o:-o)/a(r*r+l*l),u=c*l,f=-c*r,y=t+u,x=n+f,p=i+u,v=e+f,d=(y+p)/2,T=(x+v)/2,g=p-y,m=v-x,b=g*g+m*m,w=s-o,k=y*v-p*x,N=(m<0?-1:1)*a(h(0,w*w*b-k*k)),S=(k*m-g*N)/b,E=(-k*g-m*N)/b,A=(k*m+g*N)/b,P=(-k*g+m*N)/b,M=S-d,C=E-T,R=A-d,O=P-T;return M*M+C*C>R*R+O*O&&(S=A,E=P),{cx:S,cy:E,x01:-u,y01:-f,x11:S*(s/w-1),y11:E*(s/w-1)}}var k=Array.prototype.slice;function N(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function S(t){this._context=t}function E(t){return new S(t)}function A(t){return t[0]}function P(t){return t[1]}function M(t,n){var e=i(!0),s=null,o=E,h=null,_=p(r);function r(i){var r,a,l,c=(i=N(i)).length,u=!1;for(null==s&&(h=o(l=_())),r=0;r<=c;++r)!(r<c&&e(a=i[r],r,i))===u&&((u=!u)?h.lineStart():h.lineEnd()),u&&h.point(+t(a,r,i),+n(a,r,i));if(l)return h=null,l+""||null}return t="function"==typeof t?t:void 0===t?A:i(t),n="function"==typeof n?n:void 0===n?P:i(n),r.x=function(n){return arguments.length?(t="function"==typeof n?n:i(+n),r):t},r.y=function(t){return arguments.length?(n="function"==typeof t?t:i(+t),r):n},r.defined=function(t){return arguments.length?(e="function"==typeof t?t:i(!!t),r):e},r.curve=function(t){return arguments.length?(o=t,null!=s&&(h=o(s)),r):o},r.context=function(t){return arguments.length?(null==t?s=h=null:h=o(s=t),r):s},r}function C(t,n,e){var s=null,o=i(!0),h=null,_=E,r=null,a=p(l);function l(i){var l,c,u,f,y,x=(i=N(i)).length,p=!1,v=new Array(x),d=new Array(x);for(null==h&&(r=_(y=a())),l=0;l<=x;++l){if(!(l<x&&o(f=i[l],l,i))===p)if(p=!p)c=l,r.areaStart(),r.lineStart();else{for(r.lineEnd(),r.lineStart(),u=l-1;u>=c;--u)r.point(v[u],d[u]);r.lineEnd(),r.areaEnd()}p&&(v[l]=+t(f,l,i),d[l]=+n(f,l,i),r.point(s?+s(f,l,i):v[l],e?+e(f,l,i):d[l]))}if(y)return r=null,y+""||null}function c(){return M().defined(o).curve(_).context(h)}return t="function"==typeof t?t:void 0===t?A:i(+t),n="function"==typeof n?n:i(void 0===n?0:+n),e="function"==typeof e?e:void 0===e?P:i(+e),l.x=function(n){return arguments.length?(t="function"==typeof n?n:i(+n),s=null,l):t},l.x0=function(n){return arguments.length?(t="function"==typeof n?n:i(+n),l):t},l.x1=function(t){return arguments.length?(s=null==t?null:"function"==typeof t?t:i(+t),l):s},l.y=function(t){return arguments.length?(n="function"==typeof t?t:i(+t),e=null,l):n},l.y0=function(t){return arguments.length?(n="function"==typeof t?t:i(+t),l):n},l.y1=function(t){return arguments.length?(e=null==t?null:"function"==typeof t?t:i(+t),l):e},l.lineX0=l.lineY0=function(){return c().x(t).y(n)},l.lineY1=function(){return c().x(t).y(e)},l.lineX1=function(){return c().x(s).y(n)},l.defined=function(t){return arguments.length?(o="function"==typeof t?t:i(!!t),l):o},l.curve=function(t){return arguments.length?(_=t,null!=h&&(r=_(h)),l):_},l.context=function(t){return arguments.length?(null==t?h=r=null:r=_(h=t),l):h},l}function R(t,n){return n<t?-1:n>t?1:n>=t?0:NaN}function O(t){return t}S.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._context.lineTo(t,n)}}};var z=Y(E);function X(t){this._curve=t}function Y(t){function n(n){return new X(t(n))}return n._curve=t,n}function q(t){var n=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?n(Y(t)):n()._curve},t}function B(){return q(M().curve(z))}function D(){var t=C().curve(z),n=t.curve,i=t.lineX0,e=t.lineX1,s=t.lineY0,o=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return q(i())},delete t.lineX0,t.lineEndAngle=function(){return q(e())},delete t.lineX1,t.lineInnerRadius=function(){return q(s())},delete t.lineY0,t.lineOuterRadius=function(){return q(o())},delete t.lineY1,t.curve=function(t){return arguments.length?n(Y(t)):n()._curve},t}function I(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}X.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};class j{constructor(t,n){this._context=t,this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line}point(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._x?this._context.bezierCurveTo(this._x0=(this._x0+t)/2,this._y0,this._x0,n,t,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,t,this._y0,t,n)}this._x0=t,this._y0=n}}class L{constructor(t){this._context=t}lineStart(){this._point=0}lineEnd(){}point(t,n){if(t=+t,n=+n,0===this._point)this._point=1;else{const i=I(this._x0,this._y0),e=I(this._x0,this._y0=(this._y0+n)/2),s=I(t,this._y0),o=I(t,n);this._context.moveTo(...i),this._context.bezierCurveTo(...e,...s,...o)}this._x0=t,this._y0=n}}function V(t){return new j(t,!0)}function W(t){return new j(t,!1)}function F(t){return new L(t)}function H(t){return t.source}function $(t){return t.target}function G(t){let n=H,e=$,s=A,o=P,h=null,_=null,r=p(a);function a(){let i;const a=k.call(arguments),l=n.apply(this,a),c=e.apply(this,a);if(null==h&&(_=t(i=r())),_.lineStart(),a[0]=l,_.point(+s.apply(this,a),+o.apply(this,a)),a[0]=c,_.point(+s.apply(this,a),+o.apply(this,a)),_.lineEnd(),i)return _=null,i+""||null}return a.source=function(t){return arguments.length?(n=t,a):n},a.target=function(t){return arguments.length?(e=t,a):e},a.x=function(t){return arguments.length?(s="function"==typeof t?t:i(+t),a):s},a.y=function(t){return arguments.length?(o="function"==typeof t?t:i(+t),a):o},a.context=function(n){return arguments.length?(null==n?h=_=null:_=t(h=n),a):h},a}const J=a(3);var K={draw(t,n){const i=.59436*a(n+_(n/28,.75)),e=i/2,s=e*J;t.moveTo(0,i),t.lineTo(0,-i),t.moveTo(-s,-e),t.lineTo(s,e),t.moveTo(-s,e),t.lineTo(s,-e)}},Q={draw(t,n){const i=a(n/c);t.moveTo(i,0),t.arc(0,0,i,0,f)}},U={draw(t,n){const i=a(n/5)/2;t.moveTo(-3*i,-i),t.lineTo(-i,-i),t.lineTo(-i,-3*i),t.lineTo(i,-3*i),t.lineTo(i,-i),t.lineTo(3*i,-i),t.lineTo(3*i,i),t.lineTo(i,i),t.lineTo(i,3*i),t.lineTo(-i,3*i),t.lineTo(-i,i),t.lineTo(-3*i,i),t.closePath()}};const Z=a(1/3),tt=2*Z;var nt={draw(t,n){const i=a(n/tt),e=i*Z;t.moveTo(0,-i),t.lineTo(e,0),t.lineTo(0,i),t.lineTo(-e,0),t.closePath()}},it={draw(t,n){const i=.62625*a(n);t.moveTo(0,-i),t.lineTo(i,0),t.lineTo(0,i),t.lineTo(-i,0),t.closePath()}},et={draw(t,n){const i=.87559*a(n-_(n/7,2));t.moveTo(-i,0),t.lineTo(i,0),t.moveTo(0,i),t.lineTo(0,-i)}},st={draw(t,n){const i=a(n),e=-i/2;t.rect(e,e,i,i)}},ot={draw(t,n){const i=.4431*a(n);t.moveTo(i,i),t.lineTo(i,-i),t.lineTo(-i,-i),t.lineTo(-i,i),t.closePath()}};const ht=r(c/10)/r(7*c/10),_t=r(f/10)*ht,rt=-o(f/10)*ht;var at={draw(t,n){const i=a(.8908130915292852*n),e=_t*i,s=rt*i;t.moveTo(0,-i),t.lineTo(e,s);for(let n=1;n<5;++n){const h=f*n/5,_=o(h),a=r(h);t.lineTo(a*i,-_*i),t.lineTo(_*e-a*s,a*e+_*s)}t.closePath()}};const lt=a(3);var ct={draw(t,n){const i=-a(n/(3*lt));t.moveTo(0,2*i),t.lineTo(-lt*i,-i),t.lineTo(lt*i,-i),t.closePath()}};const ut=a(3);var ft={draw(t,n){const i=.6824*a(n),e=i/2,s=i*ut/2;t.moveTo(0,-i),t.lineTo(s,e),t.lineTo(-s,e),t.closePath()}};const yt=-.5,xt=a(3)/2,pt=1/a(12),vt=3*(pt/2+1);var dt={draw(t,n){const i=a(n/vt),e=i/2,s=i*pt,o=e,h=i*pt+i,_=-o,r=h;t.moveTo(e,s),t.lineTo(o,h),t.lineTo(_,r),t.lineTo(yt*e-xt*s,xt*e+yt*s),t.lineTo(yt*o-xt*h,xt*o+yt*h),t.lineTo(yt*_-xt*r,xt*_+yt*r),t.lineTo(yt*e+xt*s,yt*s-xt*e),t.lineTo(yt*o+xt*h,yt*h-xt*o),t.lineTo(yt*_+xt*r,yt*r-xt*_),t.closePath()}},Tt={draw(t,n){const i=.6189*a(n-_(n/6,1.7));t.moveTo(-i,-i),t.lineTo(i,i),t.moveTo(-i,i),t.lineTo(i,-i)}};const gt=[Q,U,nt,st,at,ct,dt],mt=[Q,et,Tt,ft,K,ot,it];function bt(){}function wt(t,n,i){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+i)/6)}function kt(t){this._context=t}function Nt(t){this._context=t}function St(t){this._context=t}function Et(t,n){this._basis=new kt(t),this._beta=n}kt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:wt(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:wt(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Nt.prototype={areaStart:bt,areaEnd:bt,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x2=t,this._y2=n;break;case 1:this._point=2,this._x3=t,this._y3=n;break;case 2:this._point=3,this._x4=t,this._y4=n,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:wt(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},St.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var i=(this._x0+4*this._x1+t)/6,e=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(i,e):this._context.moveTo(i,e);break;case 3:this._point=4;default:wt(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Et.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,i=t.length-1;if(i>0)for(var e,s=t[0],o=n[0],h=t[i]-s,_=n[i]-o,r=-1;++r<=i;)e=r/i,this._basis.point(this._beta*t[r]+(1-this._beta)*(s+e*h),this._beta*n[r]+(1-this._beta)*(o+e*_));this._x=this._y=null,this._basis.lineEnd()},point:function(t,n){this._x.push(+t),this._y.push(+n)}};var At=function t(n){function i(t){return 1===n?new kt(t):new Et(t,n)}return i.beta=function(n){return t(+n)},i}(.85);function Pt(t,n,i){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-i),t._x2,t._y2)}function Mt(t,n){this._context=t,this._k=(1-n)/6}Mt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Pt(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2,this._x1=t,this._y1=n;break;case 2:this._point=3;default:Pt(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Ct=function t(n){function i(t){return new Mt(t,n)}return i.tension=function(n){return t(+n)},i}(0);function Rt(t,n){this._context=t,this._k=(1-n)/6}Rt.prototype={areaStart:bt,areaEnd:bt,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Pt(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Ot=function t(n){function i(t){return new Rt(t,n)}return i.tension=function(n){return t(+n)},i}(0);function zt(t,n){this._context=t,this._k=(1-n)/6}zt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Pt(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Xt=function t(n){function i(t){return new zt(t,n)}return i.tension=function(n){return t(+n)},i}(0);function Yt(t,n,i){var e=t._x1,s=t._y1,o=t._x2,h=t._y2;if(t._l01_a>l){var _=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,r=3*t._l01_a*(t._l01_a+t._l12_a);e=(e*_-t._x0*t._l12_2a+t._x2*t._l01_2a)/r,s=(s*_-t._y0*t._l12_2a+t._y2*t._l01_2a)/r}if(t._l23_a>l){var a=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,c=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*a+t._x1*t._l23_2a-n*t._l12_2a)/c,h=(h*a+t._y1*t._l23_2a-i*t._l12_2a)/c}t._context.bezierCurveTo(e,s,o,h,t._x2,t._y2)}function qt(t,n){this._context=t,this._alpha=n}qt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var i=this._x2-t,e=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(i*i+e*e,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:Yt(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Bt=function t(n){function i(t){return n?new qt(t,n):new Mt(t,0)}return i.alpha=function(n){return t(+n)},i}(.5);function Dt(t,n){this._context=t,this._alpha=n}Dt.prototype={areaStart:bt,areaEnd:bt,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){if(t=+t,n=+n,this._point){var i=this._x2-t,e=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(i*i+e*e,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Yt(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var It=function t(n){function i(t){return n?new Dt(t,n):new Rt(t,0)}return i.alpha=function(n){return t(+n)},i}(.5);function jt(t,n){this._context=t,this._alpha=n}jt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var i=this._x2-t,e=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(i*i+e*e,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Yt(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Lt=function t(n){function i(t){return n?new jt(t,n):new zt(t,0)}return i.alpha=function(n){return t(+n)},i}(.5);function Vt(t){this._context=t}function Wt(t){return t<0?-1:1}function Ft(t,n,i){var e=t._x1-t._x0,s=n-t._x1,o=(t._y1-t._y0)/(e||s<0&&-0),h=(i-t._y1)/(s||e<0&&-0),_=(o*s+h*e)/(e+s);return(Wt(o)+Wt(h))*Math.min(Math.abs(o),Math.abs(h),.5*Math.abs(_))||0}function Ht(t,n){var i=t._x1-t._x0;return i?(3*(t._y1-t._y0)/i-n)/2:n}function $t(t,n,i){var e=t._x0,s=t._y0,o=t._x1,h=t._y1,_=(o-e)/3;t._context.bezierCurveTo(e+_,s+_*n,o-_,h-_*i,o,h)}function Gt(t){this._context=t}function Jt(t){this._context=new Kt(t)}function Kt(t){this._context=t}function Qt(t){this._context=t}function Ut(t){var n,i,e=t.length-1,s=new Array(e),o=new Array(e),h=new Array(e);for(s[0]=0,o[0]=2,h[0]=t[0]+2*t[1],n=1;n<e-1;++n)s[n]=1,o[n]=4,h[n]=4*t[n]+2*t[n+1];for(s[e-1]=2,o[e-1]=7,h[e-1]=8*t[e-1]+t[e],n=1;n<e;++n)i=s[n]/o[n-1],o[n]-=i,h[n]-=i*h[n-1];for(s[e-1]=h[e-1]/o[e-1],n=e-2;n>=0;--n)s[n]=(h[n]-s[n+1])/o[n];for(o[e-1]=(t[e]+s[e-1])/2,n=0;n<e-1;++n)o[n]=2*t[n+1]-s[n+1];return[s,o]}function Zt(t,n){this._context=t,this._t=n}function tn(t,n){if((s=t.length)>1)for(var i,e,s,o=1,h=t[n[0]],_=h.length;o<s;++o)for(e=h,h=t[n[o]],i=0;i<_;++i)h[i][1]+=h[i][0]=isNaN(e[i][1])?e[i][0]:e[i][1]}function nn(t){for(var n=t.length,i=new Array(n);--n>=0;)i[n]=n;return i}function en(t,n){return t[n]}function sn(t){const n=[];return n.key=t,n}function on(t){var n=t.map(hn);return nn(t).sort((function(t,i){return n[t]-n[i]}))}function hn(t){for(var n,i=-1,e=0,s=t.length,o=-1/0;++i<s;)(n=+t[i][1])>o&&(o=n,e=i);return e}function _n(t){var n=t.map(rn);return nn(t).sort((function(t,i){return n[t]-n[i]}))}function rn(t){for(var n,i=0,e=-1,s=t.length;++e<s;)(n=+t[e][1])&&(i+=n);return i}Vt.prototype={areaStart:bt,areaEnd:bt,lineStart:function(){this._point=0},lineEnd:function(){this._point&&this._context.closePath()},point:function(t,n){t=+t,n=+n,this._point?this._context.lineTo(t,n):(this._point=1,this._context.moveTo(t,n))}},Gt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=this._t0=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x1,this._y1);break;case 3:$t(this,this._t0,Ht(this,this._t0))}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){var i=NaN;if(n=+n,(t=+t)!==this._x1||n!==this._y1){switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,$t(this,Ht(this,i=Ft(this,t,n)),i);break;default:$t(this,this._t0,i=Ft(this,t,n))}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n,this._t0=i}}},(Jt.prototype=Object.create(Gt.prototype)).point=function(t,n){Gt.prototype.point.call(this,n,t)},Kt.prototype={moveTo:function(t,n){this._context.moveTo(n,t)},closePath:function(){this._context.closePath()},lineTo:function(t,n){this._context.lineTo(n,t)},bezierCurveTo:function(t,n,i,e,s,o){this._context.bezierCurveTo(n,t,e,i,o,s)}},Qt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x=[],this._y=[]},lineEnd:function(){var t=this._x,n=this._y,i=t.length;if(i)if(this._line?this._context.lineTo(t[0],n[0]):this._context.moveTo(t[0],n[0]),2===i)this._context.lineTo(t[1],n[1]);else for(var e=Ut(t),s=Ut(n),o=0,h=1;h<i;++o,++h)this._context.bezierCurveTo(e[0][o],s[0][o],e[1][o],s[1][o],t[h],n[h]);(this._line||0!==this._line&&1===i)&&this._context.closePath(),this._line=1-this._line,this._x=this._y=null},point:function(t,n){this._x.push(+t),this._y.push(+n)}},Zt.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x=this._y=NaN,this._point=0},lineEnd:function(){0<this._t&&this._t<1&&2===this._point&&this._context.lineTo(this._x,this._y),(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line>=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,n),this._context.lineTo(t,n);else{var i=this._x*(1-this._t)+t*this._t;this._context.lineTo(i,this._y),this._context.lineTo(i,n)}}this._x=t,this._y=n}},t.arc=function(){var t=v,n=d,h=i(0),k=null,N=T,S=g,E=m,A=null,P=p(M);function M(){var i,p,v=+t.apply(this,arguments),d=+n.apply(this,arguments),T=N.apply(this,arguments)-u,g=S.apply(this,arguments)-u,m=e(g-T),M=g>T;if(A||(A=i=P()),d<v&&(p=d,d=v,v=p),d>l)if(m>f-l)A.moveTo(d*o(T),d*r(T)),A.arc(0,0,d,T,g,!M),v>l&&(A.moveTo(v*o(g),v*r(g)),A.arc(0,0,v,g,T,M));else{var C,R,O=T,z=g,X=T,Y=g,q=m,B=m,D=E.apply(this,arguments)/2,I=D>l&&(k?+k.apply(this,arguments):a(v*v+d*d)),j=_(e(d-v)/2,+h.apply(this,arguments)),L=j,V=j;if(I>l){var W=x(I/v*r(D)),F=x(I/d*r(D));(q-=2*W)>l?(X+=W*=M?1:-1,Y-=W):(q=0,X=Y=(T+g)/2),(B-=2*F)>l?(O+=F*=M?1:-1,z-=F):(B=0,O=z=(T+g)/2)}var H=d*o(O),$=d*r(O),G=v*o(Y),J=v*r(Y);if(j>l){var K,Q=d*o(z),U=d*r(z),Z=v*o(X),tt=v*r(X);if(m<c)if(K=b(H,$,Z,tt,Q,U,G,J)){var nt=H-K[0],it=$-K[1],et=Q-K[0],st=U-K[1],ot=1/r(y((nt*et+it*st)/(a(nt*nt+it*it)*a(et*et+st*st)))/2),ht=a(K[0]*K[0]+K[1]*K[1]);L=_(j,(v-ht)/(ot-1)),V=_(j,(d-ht)/(ot+1))}else L=V=0}B>l?V>l?(C=w(Z,tt,H,$,d,V,M),R=w(Q,U,G,J,d,V,M),A.moveTo(C.cx+C.x01,C.cy+C.y01),V<j?A.arc(C.cx,C.cy,V,s(C.y01,C.x01),s(R.y01,R.x01),!M):(A.arc(C.cx,C.cy,V,s(C.y01,C.x01),s(C.y11,C.x11),!M),A.arc(0,0,d,s(C.cy+C.y11,C.cx+C.x11),s(R.cy+R.y11,R.cx+R.x11),!M),A.arc(R.cx,R.cy,V,s(R.y11,R.x11),s(R.y01,R.x01),!M))):(A.moveTo(H,$),A.arc(0,0,d,O,z,!M)):A.moveTo(H,$),v>l&&q>l?L>l?(C=w(G,J,Q,U,v,-L,M),R=w(H,$,Z,tt,v,-L,M),A.lineTo(C.cx+C.x01,C.cy+C.y01),L<j?A.arc(C.cx,C.cy,L,s(C.y01,C.x01),s(R.y01,R.x01),!M):(A.arc(C.cx,C.cy,L,s(C.y01,C.x01),s(C.y11,C.x11),!M),A.arc(0,0,v,s(C.cy+C.y11,C.cx+C.x11),s(R.cy+R.y11,R.cx+R.x11),M),A.arc(R.cx,R.cy,L,s(R.y11,R.x11),s(R.y01,R.x01),!M))):A.arc(0,0,v,Y,X,M):A.lineTo(G,J)}else A.moveTo(0,0);if(A.closePath(),i)return A=null,i+""||null}return M.centroid=function(){var i=(+t.apply(this,arguments)+ +n.apply(this,arguments))/2,e=(+N.apply(this,arguments)+ +S.apply(this,arguments))/2-c/2;return[o(e)*i,r(e)*i]},M.innerRadius=function(n){return arguments.length?(t="function"==typeof n?n:i(+n),M):t},M.outerRadius=function(t){return arguments.length?(n="function"==typeof t?t:i(+t),M):n},M.cornerRadius=function(t){return arguments.length?(h="function"==typeof t?t:i(+t),M):h},M.padRadius=function(t){return arguments.length?(k=null==t?null:"function"==typeof t?t:i(+t),M):k},M.startAngle=function(t){return arguments.length?(N="function"==typeof t?t:i(+t),M):N},M.endAngle=function(t){return arguments.length?(S="function"==typeof t?t:i(+t),M):S},M.padAngle=function(t){return arguments.length?(E="function"==typeof t?t:i(+t),M):E},M.context=function(t){return arguments.length?(A=null==t?null:t,M):A},M},t.area=C,t.areaRadial=D,t.curveBasis=function(t){return new kt(t)},t.curveBasisClosed=function(t){return new Nt(t)},t.curveBasisOpen=function(t){return new St(t)},t.curveBumpX=V,t.curveBumpY=W,t.curveBundle=At,t.curveCardinal=Ct,t.curveCardinalClosed=Ot,t.curveCardinalOpen=Xt,t.curveCatmullRom=Bt,t.curveCatmullRomClosed=It,t.curveCatmullRomOpen=Lt,t.curveLinear=E,t.curveLinearClosed=function(t){return new Vt(t)},t.curveMonotoneX=function(t){return new Gt(t)},t.curveMonotoneY=function(t){return new Jt(t)},t.curveNatural=function(t){return new Qt(t)},t.curveStep=function(t){return new Zt(t,.5)},t.curveStepAfter=function(t){return new Zt(t,1)},t.curveStepBefore=function(t){return new Zt(t,0)},t.line=M,t.lineRadial=B,t.link=G,t.linkHorizontal=function(){return G(V)},t.linkRadial=function(){const t=G(F);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t},t.linkVertical=function(){return G(W)},t.pie=function(){var t=O,n=R,e=null,s=i(0),o=i(f),h=i(0);function _(i){var _,r,a,l,c,u=(i=N(i)).length,y=0,x=new Array(u),p=new Array(u),v=+s.apply(this,arguments),d=Math.min(f,Math.max(-f,o.apply(this,arguments)-v)),T=Math.min(Math.abs(d)/u,h.apply(this,arguments)),g=T*(d<0?-1:1);for(_=0;_<u;++_)(c=p[x[_]=_]=+t(i[_],_,i))>0&&(y+=c);for(null!=n?x.sort((function(t,i){return n(p[t],p[i])})):null!=e&&x.sort((function(t,n){return e(i[t],i[n])})),_=0,a=y?(d-u*g)/y:0;_<u;++_,v=l)r=x[_],l=v+((c=p[r])>0?c*a:0)+g,p[r]={data:i[r],index:_,value:c,startAngle:v,endAngle:l,padAngle:T};return p}return _.value=function(n){return arguments.length?(t="function"==typeof n?n:i(+n),_):t},_.sortValues=function(t){return arguments.length?(n=t,e=null,_):n},_.sort=function(t){return arguments.length?(e=t,n=null,_):e},_.startAngle=function(t){return arguments.length?(s="function"==typeof t?t:i(+t),_):s},_.endAngle=function(t){return arguments.length?(o="function"==typeof t?t:i(+t),_):o},_.padAngle=function(t){return arguments.length?(h="function"==typeof t?t:i(+t),_):h},_},t.pointRadial=I,t.radialArea=D,t.radialLine=B,t.stack=function(){var t=i([]),n=nn,e=tn,s=en;function o(i){var o,h,_=Array.from(t.apply(this,arguments),sn),r=_.length,a=-1;for(const t of i)for(o=0,++a;o<r;++o)(_[o][a]=[0,+s(t,_[o].key,a,i)]).data=t;for(o=0,h=N(n(_));o<r;++o)_[h[o]].index=o;return e(_,h),_}return o.keys=function(n){return arguments.length?(t="function"==typeof n?n:i(Array.from(n)),o):t},o.value=function(t){return arguments.length?(s="function"==typeof t?t:i(+t),o):s},o.order=function(t){return arguments.length?(n=null==t?nn:"function"==typeof t?t:i(Array.from(t)),o):n},o.offset=function(t){return arguments.length?(e=null==t?tn:t,o):e},o},t.stackOffsetDiverging=function(t,n){if((_=t.length)>0)for(var i,e,s,o,h,_,r=0,a=t[n[0]].length;r<a;++r)for(o=h=0,i=0;i<_;++i)(s=(e=t[n[i]][r])[1]-e[0])>0?(e[0]=o,e[1]=o+=s):s<0?(e[1]=h,e[0]=h+=s):(e[0]=0,e[1]=s)},t.stackOffsetExpand=function(t,n){if((e=t.length)>0){for(var i,e,s,o=0,h=t[0].length;o<h;++o){for(s=i=0;i<e;++i)s+=t[i][o][1]||0;if(s)for(i=0;i<e;++i)t[i][o][1]/=s}tn(t,n)}},t.stackOffsetNone=tn,t.stackOffsetSilhouette=function(t,n){if((i=t.length)>0){for(var i,e=0,s=t[n[0]],o=s.length;e<o;++e){for(var h=0,_=0;h<i;++h)_+=t[h][e][1]||0;s[e][1]+=s[e][0]=-_/2}tn(t,n)}},t.stackOffsetWiggle=function(t,n){if((s=t.length)>0&&(e=(i=t[n[0]]).length)>0){for(var i,e,s,o=0,h=1;h<e;++h){for(var _=0,r=0,a=0;_<s;++_){for(var l=t[n[_]],c=l[h][1]||0,u=(c-(l[h-1][1]||0))/2,f=0;f<_;++f){var y=t[n[f]];u+=(y[h][1]||0)-(y[h-1][1]||0)}r+=c,a+=u*c}i[h-1][1]+=i[h-1][0]=o,r&&(o-=a/r)}i[h-1][1]+=i[h-1][0]=o,tn(t,n)}},t.stackOrderAppearance=on,t.stackOrderAscending=_n,t.stackOrderDescending=function(t){return _n(t).reverse()},t.stackOrderInsideOut=function(t){var n,i,e=t.length,s=t.map(rn),o=on(t),h=0,_=0,r=[],a=[];for(n=0;n<e;++n)i=o[n],h<_?(h+=s[i],r.push(i)):(_+=s[i],a.push(i));return a.reverse().concat(r)},t.stackOrderNone=nn,t.stackOrderReverse=function(t){return nn(t).reverse()},t.symbol=function(t,n){let e=null,s=p(o);function o(){let i;if(e||(e=i=s()),t.apply(this,arguments).draw(e,+n.apply(this,arguments)),i)return e=null,i+""||null}return t="function"==typeof t?t:i(t||Q),n="function"==typeof n?n:i(void 0===n?64:+n),o.type=function(n){return arguments.length?(t="function"==typeof n?n:i(n),o):t},o.size=function(t){return arguments.length?(n="function"==typeof t?t:i(+t),o):n},o.context=function(t){return arguments.length?(e=null==t?null:t,o):e},o},t.symbolAsterisk=K,t.symbolCircle=Q,t.symbolCross=U,t.symbolDiamond=nt,t.symbolDiamond2=it,t.symbolPlus=et,t.symbolSquare=st,t.symbolSquare2=ot,t.symbolStar=at,t.symbolTimes=Tt,t.symbolTriangle=ct,t.symbolTriangle2=ft,t.symbolWye=dt,t.symbolX=Tt,t.symbols=gt,t.symbolsFill=gt,t.symbolsStroke=mt}));
 
 
 
frontend/vendor/three.min.js DELETED
The diff for this file is too large to render. See raw diff