sachin1801 commited on
Commit
dcfc660
·
1 Parent(s): d963dcd

Restructure result page with 4-tab layout and auto variant analysis

Browse files

- Add 4 tabs: Exon View, Feature View, Variant Analysis, Download
- Exon View: collapsible panels for Silhouette and Heat Map
- Feature View: dropdown to filter by individual features
- Variant Analysis: auto-runs for single predictions, on-demand for batch
- Download tab: PNG export via postMessage to iframes

Backend changes:
- /api/predict now auto-runs mutagenesis (210 mutations) for single sequences
- Add GET/POST /api/result/{job_id}/mutagenesis endpoints for on-demand analysis
- Fix heatmap batch hiding bug, enable eager loading of heatmap iframe

Frontend changes:
- Add collapsible panel styles and variant analysis UI components
- Implement postMessage-based PNG download from PyShiny iframes
- Update polling timeout to 2 minutes for longer mutagenesis runs

webapp/app/api/routes.py CHANGED
@@ -107,6 +107,7 @@ async def submit_prediction(
107
  Submit a single sequence for PSI prediction.
108
 
109
  The sequence must be exactly 70 nucleotides long and contain only A, C, G, T.
 
110
  """
111
  job_id = str(uuid.uuid4())
112
 
@@ -137,8 +138,7 @@ async def submit_prediction(
137
  # Get force plot data
138
  force_plot_data = predictor.get_force_plot_data(request.sequence)
139
 
140
- # Update job with results
141
- job.status = "finished"
142
  job.psi = result["psi"]
143
  job.structure = result["structure"]
144
  job.mfe = result["mfe"]
@@ -148,6 +148,41 @@ async def submit_prediction(
148
  job.warnings = json.dumps(result["warnings"])
149
  db.commit()
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  except Exception as e:
152
  job.status = "failed"
153
  job.error_message = str(e)
@@ -382,6 +417,229 @@ async def get_job_result(
382
  )
383
 
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  @router.get("/heatmap/{job_id}", tags=["visualization"])
386
  async def get_heatmap_data(
387
  job_id: str,
 
107
  Submit a single sequence for PSI prediction.
108
 
109
  The sequence must be exactly 70 nucleotides long and contain only A, C, G, T.
110
+ Automatically runs variant analysis (mutagenesis) for single predictions.
111
  """
112
  job_id = str(uuid.uuid4())
113
 
 
138
  # Get force plot data
139
  force_plot_data = predictor.get_force_plot_data(request.sequence)
140
 
141
+ # Update job with PSI results
 
142
  job.psi = result["psi"]
143
  job.structure = result["structure"]
144
  job.mfe = result["mfe"]
 
148
  job.warnings = json.dumps(result["warnings"])
149
  db.commit()
150
 
151
+ # Automatically run variant analysis (mutagenesis) for single predictions
152
+ reference_psi = result["psi"]
153
+ mutations = generate_all_mutations(request.sequence)
154
+
155
+ mutation_results = []
156
+ for mutation in mutations:
157
+ try:
158
+ mut_result = predictor.predict_single(mutation["mutant_sequence"])
159
+ mutation_psi = mut_result["psi"]
160
+ delta_psi = calculate_delta_psi(reference_psi, mutation_psi)
161
+
162
+ mutation_results.append({
163
+ "position": mutation["position"],
164
+ "original": mutation["original"],
165
+ "mutant": mutation["mutant"],
166
+ "mutation_label": mutation["mutation_label"],
167
+ "psi": mutation_psi,
168
+ "delta_psi": delta_psi,
169
+ })
170
+ except Exception as e:
171
+ mutation_results.append({
172
+ "position": mutation["position"],
173
+ "original": mutation["original"],
174
+ "mutant": mutation["mutant"],
175
+ "mutation_label": mutation["mutation_label"],
176
+ "psi": None,
177
+ "delta_psi": None,
178
+ "error": str(e),
179
+ })
180
+
181
+ # Store mutagenesis results
182
+ job.set_mutagenesis_results(mutation_results)
183
+ job.status = "finished"
184
+ db.commit()
185
+
186
  except Exception as e:
187
  job.status = "failed"
188
  job.error_message = str(e)
 
417
  )
418
 
419
 
420
+ @router.post("/result/{job_id}/mutagenesis", tags=["results"])
421
+ async def run_mutagenesis_for_result(
422
+ job_id: str,
423
+ batch_index: Optional[int] = Query(None, description="Index of sequence in batch job (0-based)"),
424
+ db: Session = Depends(get_db),
425
+ ):
426
+ """
427
+ Run mutagenesis analysis on-demand for a specific result.
428
+
429
+ For single predictions, returns existing mutagenesis results if available.
430
+ For batch items, runs mutagenesis on the specified sequence.
431
+ """
432
+ job = db.query(Job).filter(Job.id == job_id).first()
433
+ if not job:
434
+ raise HTTPException(status_code=404, detail="Job not found")
435
+
436
+ if job.status != "finished":
437
+ raise HTTPException(status_code=400, detail="Job not yet complete")
438
+
439
+ # Determine which sequence to analyze
440
+ if job.is_batch:
441
+ if batch_index is None:
442
+ raise HTTPException(status_code=400, detail="batch_index required for batch jobs")
443
+
444
+ results = job.get_batch_results()
445
+ if batch_index >= len(results):
446
+ raise HTTPException(status_code=404, detail=f"Batch index {batch_index} not found")
447
+
448
+ seq_result = results[batch_index]
449
+ if seq_result.get("status") != "success":
450
+ raise HTTPException(status_code=400, detail="Cannot run mutagenesis on invalid sequence")
451
+
452
+ sequence = seq_result.get("sequence", "")
453
+ reference_psi = seq_result.get("psi")
454
+
455
+ # Check if mutagenesis already exists for this batch sequence
456
+ existing_mutagenesis = seq_result.get("mutagenesis_results")
457
+ if existing_mutagenesis:
458
+ heatmap_data = organize_mutations_for_heatmap(existing_mutagenesis)
459
+ top_positive, top_negative = get_top_mutations(existing_mutagenesis, n=10)
460
+ return {
461
+ "job_id": job_id,
462
+ "batch_index": batch_index,
463
+ "status": "finished",
464
+ "reference_sequence": sequence,
465
+ "reference_psi": reference_psi,
466
+ "total_mutations": 210,
467
+ "completed_mutations": len([m for m in existing_mutagenesis if m.get("psi") is not None]),
468
+ "mutations": existing_mutagenesis,
469
+ "heatmap_data": heatmap_data,
470
+ "top_positive": top_positive[:10],
471
+ "top_negative": top_negative[:10],
472
+ }
473
+ else:
474
+ # Single prediction - check for existing mutagenesis results
475
+ sequence = job.sequence
476
+ reference_psi = job.psi
477
+
478
+ existing_results = job.get_mutagenesis_results()
479
+ if existing_results:
480
+ heatmap_data = organize_mutations_for_heatmap(existing_results)
481
+ top_positive, top_negative = get_top_mutations(existing_results, n=10)
482
+ return {
483
+ "job_id": job_id,
484
+ "status": "finished",
485
+ "reference_sequence": sequence,
486
+ "reference_psi": reference_psi,
487
+ "total_mutations": 210,
488
+ "completed_mutations": len([m for m in existing_results if m.get("psi") is not None]),
489
+ "mutations": existing_results,
490
+ "heatmap_data": heatmap_data,
491
+ "top_positive": top_positive[:10],
492
+ "top_negative": top_negative[:10],
493
+ }
494
+
495
+ # Run mutagenesis analysis
496
+ try:
497
+ predictor = get_predictor()
498
+ mutations = generate_all_mutations(sequence)
499
+
500
+ mutation_results = []
501
+ for mutation in mutations:
502
+ try:
503
+ result = predictor.predict_single(mutation["mutant_sequence"])
504
+ mutation_psi = result["psi"]
505
+ delta_psi = calculate_delta_psi(reference_psi, mutation_psi)
506
+
507
+ mutation_results.append({
508
+ "position": mutation["position"],
509
+ "original": mutation["original"],
510
+ "mutant": mutation["mutant"],
511
+ "mutation_label": mutation["mutation_label"],
512
+ "psi": mutation_psi,
513
+ "delta_psi": delta_psi,
514
+ })
515
+ except Exception as e:
516
+ mutation_results.append({
517
+ "position": mutation["position"],
518
+ "original": mutation["original"],
519
+ "mutant": mutation["mutant"],
520
+ "mutation_label": mutation["mutation_label"],
521
+ "psi": None,
522
+ "delta_psi": None,
523
+ "error": str(e),
524
+ })
525
+
526
+ # Store results
527
+ if job.is_batch:
528
+ # Store in batch results
529
+ results = job.get_batch_results()
530
+ results[batch_index]["mutagenesis_results"] = mutation_results
531
+ job.set_batch_results(results)
532
+ else:
533
+ job.set_mutagenesis_results(mutation_results)
534
+ db.commit()
535
+
536
+ heatmap_data = organize_mutations_for_heatmap(mutation_results)
537
+ top_positive, top_negative = get_top_mutations(mutation_results, n=10)
538
+
539
+ response = {
540
+ "job_id": job_id,
541
+ "status": "finished",
542
+ "reference_sequence": sequence,
543
+ "reference_psi": reference_psi,
544
+ "total_mutations": 210,
545
+ "completed_mutations": len([m for m in mutation_results if m.get("psi") is not None]),
546
+ "mutations": mutation_results,
547
+ "heatmap_data": heatmap_data,
548
+ "top_positive": top_positive[:10],
549
+ "top_negative": top_negative[:10],
550
+ }
551
+ if job.is_batch:
552
+ response["batch_index"] = batch_index
553
+
554
+ return response
555
+
556
+ except Exception as e:
557
+ raise HTTPException(status_code=500, detail=f"Mutagenesis analysis failed: {str(e)}")
558
+
559
+
560
+ @router.get("/result/{job_id}/mutagenesis", tags=["results"])
561
+ async def get_mutagenesis_for_result(
562
+ job_id: str,
563
+ batch_index: Optional[int] = Query(None, description="Index of sequence in batch job (0-based)"),
564
+ db: Session = Depends(get_db),
565
+ ):
566
+ """
567
+ Get existing mutagenesis results for a job.
568
+
569
+ Returns null/empty if mutagenesis hasn't been run yet.
570
+ """
571
+ job = db.query(Job).filter(Job.id == job_id).first()
572
+ if not job:
573
+ raise HTTPException(status_code=404, detail="Job not found")
574
+
575
+ if job.status != "finished":
576
+ raise HTTPException(status_code=400, detail="Job not yet complete")
577
+
578
+ if job.is_batch:
579
+ if batch_index is None:
580
+ raise HTTPException(status_code=400, detail="batch_index required for batch jobs")
581
+
582
+ results = job.get_batch_results()
583
+ if batch_index >= len(results):
584
+ raise HTTPException(status_code=404, detail=f"Batch index {batch_index} not found")
585
+
586
+ seq_result = results[batch_index]
587
+ existing_mutagenesis = seq_result.get("mutagenesis_results")
588
+
589
+ if not existing_mutagenesis:
590
+ return {
591
+ "job_id": job_id,
592
+ "batch_index": batch_index,
593
+ "status": "not_run",
594
+ "reference_sequence": seq_result.get("sequence", ""),
595
+ "reference_psi": seq_result.get("psi"),
596
+ "mutations": None,
597
+ }
598
+
599
+ heatmap_data = organize_mutations_for_heatmap(existing_mutagenesis)
600
+ top_positive, top_negative = get_top_mutations(existing_mutagenesis, n=10)
601
+ return {
602
+ "job_id": job_id,
603
+ "batch_index": batch_index,
604
+ "status": "finished",
605
+ "reference_sequence": seq_result.get("sequence", ""),
606
+ "reference_psi": seq_result.get("psi"),
607
+ "total_mutations": 210,
608
+ "completed_mutations": len([m for m in existing_mutagenesis if m.get("psi") is not None]),
609
+ "mutations": existing_mutagenesis,
610
+ "heatmap_data": heatmap_data,
611
+ "top_positive": top_positive[:10],
612
+ "top_negative": top_negative[:10],
613
+ }
614
+ else:
615
+ # Single prediction
616
+ existing_results = job.get_mutagenesis_results()
617
+
618
+ if not existing_results:
619
+ return {
620
+ "job_id": job_id,
621
+ "status": "not_run",
622
+ "reference_sequence": job.sequence,
623
+ "reference_psi": job.psi,
624
+ "mutations": None,
625
+ }
626
+
627
+ heatmap_data = organize_mutations_for_heatmap(existing_results)
628
+ top_positive, top_negative = get_top_mutations(existing_results, n=10)
629
+ return {
630
+ "job_id": job_id,
631
+ "status": "finished",
632
+ "reference_sequence": job.sequence,
633
+ "reference_psi": job.psi,
634
+ "total_mutations": 210,
635
+ "completed_mutations": len([m for m in existing_results if m.get("psi") is not None]),
636
+ "mutations": existing_results,
637
+ "heatmap_data": heatmap_data,
638
+ "top_positive": top_positive[:10],
639
+ "top_negative": top_negative[:10],
640
+ }
641
+
642
+
643
  @router.get("/heatmap/{job_id}", tags=["visualization"])
644
  async def get_heatmap_data(
645
  job_id: str,
webapp/app/shiny_apps/heatmap_app.py CHANGED
@@ -98,6 +98,34 @@ def create_app(api_base_url: str = "http://localhost:8000"):
98
  });
99
  }
100
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  });
102
  // Request params from parent when Shiny is ready
103
  document.addEventListener('shiny:connected', function() {
@@ -438,11 +466,22 @@ def create_app(api_base_url: str = "http://localhost:8000"):
438
  ),
439
  )
440
 
441
- # Convert to HTML
442
  html_content = fig.to_html(
443
  full_html=False,
444
  include_plotlyjs="cdn",
445
- config={"responsive": True},
 
 
 
 
 
 
 
 
 
 
 
446
  )
447
 
448
  return ui.HTML(html_content)
 
98
  });
99
  }
100
  }
101
+ // Handle download request from parent
102
+ if (event.data && event.data.type === 'downloadRequest') {
103
+ console.log('[Heatmap] Download requested');
104
+ var plotDiv = document.querySelector('.js-plotly-plot');
105
+ if (plotDiv) {
106
+ Plotly.toImage(plotDiv, {format: 'png', width: 1200, height: 700, scale: 2})
107
+ .then(function(dataUrl) {
108
+ window.parent.postMessage({
109
+ type: 'downloadResponse',
110
+ source: 'heatmap',
111
+ dataUrl: dataUrl
112
+ }, '*');
113
+ })
114
+ .catch(function(err) {
115
+ window.parent.postMessage({
116
+ type: 'downloadResponse',
117
+ source: 'heatmap',
118
+ error: err.toString()
119
+ }, '*');
120
+ });
121
+ } else {
122
+ window.parent.postMessage({
123
+ type: 'downloadResponse',
124
+ source: 'heatmap',
125
+ error: 'Plot not ready'
126
+ }, '*');
127
+ }
128
+ }
129
  });
130
  // Request params from parent when Shiny is ready
131
  document.addEventListener('shiny:connected', function() {
 
466
  ),
467
  )
468
 
469
+ # Convert to HTML with download button enabled
470
  html_content = fig.to_html(
471
  full_html=False,
472
  include_plotlyjs="cdn",
473
+ config={
474
+ "responsive": True,
475
+ "toImageButtonOptions": {
476
+ "format": "png",
477
+ "filename": "heatmap_view",
478
+ "height": 700,
479
+ "width": 1200,
480
+ "scale": 2
481
+ },
482
+ "displayModeBar": True,
483
+ "modeBarButtonsToAdd": ["toImage"],
484
+ },
485
  )
486
 
487
  return ui.HTML(html_content)
webapp/app/shiny_apps/silhouette_app.py CHANGED
@@ -106,6 +106,24 @@ def create_app(api_base_url: str = "http://localhost:8000"):
106
  });
107
  }
108
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  });
110
  // Request params from parent when Shiny is ready
111
  document.addEventListener('shiny:connected', function() {
@@ -407,11 +425,11 @@ def create_app(api_base_url: str = "http://localhost:8000"):
407
 
408
  plt.tight_layout()
409
 
410
- # Convert to HTML
411
  import io
412
  import base64
413
  buf = io.BytesIO()
414
- fig.savefig(buf, format='png', dpi=100, bbox_inches='tight')
415
  buf.seek(0)
416
  img_base64 = base64.b64encode(buf.read()).decode('utf-8')
417
  plt.close(fig)
 
106
  });
107
  }
108
  }
109
+ // Handle download request from parent
110
+ if (event.data && event.data.type === 'downloadRequest') {
111
+ console.log('[Silhouette] Download requested');
112
+ var img = document.querySelector('.plot-container img');
113
+ if (img && img.src) {
114
+ window.parent.postMessage({
115
+ type: 'downloadResponse',
116
+ source: 'silhouette',
117
+ dataUrl: img.src
118
+ }, '*');
119
+ } else {
120
+ window.parent.postMessage({
121
+ type: 'downloadResponse',
122
+ source: 'silhouette',
123
+ error: 'Image not ready'
124
+ }, '*');
125
+ }
126
+ }
127
  });
128
  // Request params from parent when Shiny is ready
129
  document.addEventListener('shiny:connected', function() {
 
425
 
426
  plt.tight_layout()
427
 
428
+ # Convert to PNG
429
  import io
430
  import base64
431
  buf = io.BytesIO()
432
+ fig.savefig(buf, format='png', dpi=150, bbox_inches='tight')
433
  buf.seek(0)
434
  img_base64 = base64.b64encode(buf.read()).decode('utf-8')
435
  plt.close(fig)
webapp/static/css/custom.css CHANGED
@@ -230,3 +230,124 @@ a:focus:not(:focus-visible) {
230
  #filter-chevron.rotate-180 {
231
  transform: rotate(180deg);
232
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  #filter-chevron.rotate-180 {
231
  transform: rotate(180deg);
232
  }
233
+
234
+ /* Collapsible panel styles */
235
+ .collapsible-panel {
236
+ border: 1px solid #e5e7eb;
237
+ border-radius: 8px;
238
+ margin-bottom: 16px;
239
+ overflow: hidden;
240
+ }
241
+
242
+ .collapsible-header {
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: space-between;
246
+ padding: 12px 16px;
247
+ background-color: #f9fafb;
248
+ cursor: pointer;
249
+ user-select: none;
250
+ transition: background-color 0.15s ease;
251
+ }
252
+
253
+ .collapsible-header:hover {
254
+ background-color: #f3f4f6;
255
+ }
256
+
257
+ .collapsible-header h3 {
258
+ margin: 0;
259
+ font-size: 14px;
260
+ font-weight: 600;
261
+ color: #374151;
262
+ }
263
+
264
+ .collapsible-chevron {
265
+ transition: transform 0.2s ease;
266
+ color: #6b7280;
267
+ }
268
+
269
+ .collapsible-header.expanded .collapsible-chevron {
270
+ transform: rotate(180deg);
271
+ }
272
+
273
+ .collapsible-content {
274
+ display: none;
275
+ padding: 16px;
276
+ border-top: 1px solid #e5e7eb;
277
+ }
278
+
279
+ .collapsible-content.expanded {
280
+ display: block;
281
+ }
282
+
283
+ /* Variant analysis styles */
284
+ .mutation-table {
285
+ width: 100%;
286
+ border-collapse: collapse;
287
+ font-size: 13px;
288
+ }
289
+
290
+ .mutation-table th,
291
+ .mutation-table td {
292
+ padding: 8px 12px;
293
+ text-align: left;
294
+ border-bottom: 1px solid #e5e7eb;
295
+ }
296
+
297
+ .mutation-table th {
298
+ background-color: #f9fafb;
299
+ font-weight: 600;
300
+ color: #374151;
301
+ }
302
+
303
+ .mutation-table tr:hover {
304
+ background-color: #f9fafb;
305
+ }
306
+
307
+ .delta-positive {
308
+ color: #059669;
309
+ font-weight: 500;
310
+ }
311
+
312
+ .delta-negative {
313
+ color: #dc2626;
314
+ font-weight: 500;
315
+ }
316
+
317
+ /* Download tab styles */
318
+ .download-checkbox-group label {
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 8px;
322
+ padding: 12px 16px;
323
+ margin-bottom: 8px;
324
+ background-color: #f9fafb;
325
+ border: 1px solid #e5e7eb;
326
+ border-radius: 6px;
327
+ cursor: pointer;
328
+ transition: background-color 0.15s ease, border-color 0.15s ease;
329
+ }
330
+
331
+ .download-checkbox-group label:hover {
332
+ background-color: #f3f4f6;
333
+ border-color: #d1d5db;
334
+ }
335
+
336
+ .download-checkbox-group input[type="checkbox"]:checked + span {
337
+ color: #2563eb;
338
+ }
339
+
340
+ /* Loading spinner for variant analysis */
341
+ .variant-loading {
342
+ display: flex;
343
+ flex-direction: column;
344
+ align-items: center;
345
+ justify-content: center;
346
+ padding: 40px 20px;
347
+ color: #6b7280;
348
+ }
349
+
350
+ .variant-loading svg {
351
+ animation: spin 1s linear infinite;
352
+ margin-bottom: 12px;
353
+ }
webapp/static/js/result.js CHANGED
@@ -20,7 +20,7 @@ const forcePlotEl = document.getElementById('force-plot');
20
 
21
  // Polling configuration
22
  const POLL_INTERVAL = 1000; // 1 second
23
- const MAX_POLLS = 60; // 1 minute max
24
  let pollCount = 0;
25
 
26
  /**
@@ -59,14 +59,8 @@ function displayResults(data) {
59
  loadingState.classList.add('hidden');
60
  resultsContainer.classList.remove('hidden');
61
 
62
- // For batch sequences, hide elements that don't support batch sequence detail
63
  if (typeof batchIndex !== 'undefined' && batchIndex !== null) {
64
- // Hide heatmap tab (shiny app doesn't support batch sequence detail)
65
- const heatmapTab = document.getElementById('tab-heatmap');
66
- if (heatmapTab) {
67
- heatmapTab.classList.add('hidden');
68
- }
69
- // Hide CSV download link (not available for individual batch sequences)
70
  const csvLink = document.querySelector('a[href*="/api/export/"]');
71
  if (csvLink) {
72
  csvLink.classList.add('hidden');
@@ -251,13 +245,17 @@ async function fetchResult() {
251
 
252
  const data = await response.json();
253
 
254
- if (data.status === 'completed') {
255
  displayResults(data);
256
  } else if (data.status === 'failed') {
257
- showError(data.error || 'Prediction failed');
258
- } else if (data.status === 'pending' || data.status === 'processing') {
259
- // Update loading text
260
- loadingText.textContent = `Processing... (${pollCount + 1}s)`;
 
 
 
 
261
 
262
  // Continue polling
263
  pollCount++;
 
20
 
21
  // Polling configuration
22
  const POLL_INTERVAL = 1000; // 1 second
23
+ const MAX_POLLS = 120; // 2 minutes max (mutagenesis takes ~45 seconds)
24
  let pollCount = 0;
25
 
26
  /**
 
59
  loadingState.classList.add('hidden');
60
  resultsContainer.classList.remove('hidden');
61
 
62
+ // For batch sequences, hide CSV download link (not available for individual batch sequences)
63
  if (typeof batchIndex !== 'undefined' && batchIndex !== null) {
 
 
 
 
 
 
64
  const csvLink = document.querySelector('a[href*="/api/export/"]');
65
  if (csvLink) {
66
  csvLink.classList.add('hidden');
 
245
 
246
  const data = await response.json();
247
 
248
+ if (data.status === 'completed' || data.status === 'finished') {
249
  displayResults(data);
250
  } else if (data.status === 'failed') {
251
+ showError(data.error || data.error_message || 'Prediction failed');
252
+ } else if (data.status === 'pending' || data.status === 'processing' || data.status === 'queued' || data.status === 'running') {
253
+ // Update loading text with progress indication
254
+ let statusText = 'Processing';
255
+ if (data.status === 'running') {
256
+ statusText = pollCount < 5 ? 'Running prediction' : 'Running variant analysis';
257
+ }
258
+ loadingText.textContent = `${statusText}... (${pollCount + 1}s)`;
259
 
260
  // Continue polling
261
  pollCount++;
webapp/templates/landing.html CHANGED
@@ -28,23 +28,18 @@
28
  </p>
29
  </a>
30
 
31
- <!-- Mutagenesis - Coming Soon -->
32
- <div class="block bg-gray-50 rounded-lg border border-gray-200 p-6 opacity-75 cursor-not-allowed">
33
- <div class="flex items-center justify-between mb-4">
34
- <div class="flex items-center justify-center w-12 h-12 bg-gray-200 rounded-lg">
35
- <svg class="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
36
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
37
- </svg>
38
- </div>
39
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
40
- Coming Soon
41
- </span>
42
  </div>
43
- <h3 class="text-lg font-semibold text-gray-500 mb-2">Mutagenesis Prediction</h3>
44
- <p class="text-sm text-gray-400">
45
  Analyze how single nucleotide mutations affect splicing outcomes across your sequence.
46
  </p>
47
- </div>
48
 
49
  <!-- Exon Comparison - Coming Soon -->
50
  <div class="block bg-gray-50 rounded-lg border border-gray-200 p-6 opacity-75 cursor-not-allowed">
 
28
  </p>
29
  </a>
30
 
31
+ <!-- Mutagenesis - Active -->
32
+ <a href="/mutagenesis" class="block bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md hover:border-primary-300 transition-all">
33
+ <div class="flex items-center justify-center w-12 h-12 bg-primary-100 rounded-lg mb-4">
34
+ <svg class="w-6 h-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
35
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
36
+ </svg>
 
 
 
 
 
37
  </div>
38
+ <h3 class="text-lg font-semibold text-gray-900 mb-2">Mutagenesis Analysis</h3>
39
+ <p class="text-sm text-gray-600">
40
  Analyze how single nucleotide mutations affect splicing outcomes across your sequence.
41
  </p>
42
+ </a>
43
 
44
  <!-- Exon Comparison - Coming Soon -->
45
  <div class="block bg-gray-50 rounded-lg border border-gray-200 p-6 opacity-75 cursor-not-allowed">
webapp/templates/result.html CHANGED
@@ -74,59 +74,251 @@
74
  <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
75
  <!-- Tab Navigation -->
76
  <div class="border-b border-gray-200">
77
- <nav class="flex" role="tablist" aria-label="Visualization tabs">
78
  <button type="button"
79
- id="tab-silhouette"
80
  role="tab"
81
  aria-selected="true"
82
- aria-controls="panel-silhouette"
83
  class="px-6 py-3 text-sm font-medium border-b-2 border-primary-500 text-primary-600 focus:outline-none"
84
- onclick="switchTab('silhouette')">
85
- Silhouette View
86
  </button>
87
  <button type="button"
88
- id="tab-heatmap"
89
  role="tab"
90
  aria-selected="false"
91
- aria-controls="panel-heatmap"
92
  class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none"
93
- onclick="switchTab('heatmap')">
94
- Filter Heatmap
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  </button>
96
  </nav>
97
  </div>
98
 
99
- <!-- Silhouette Panel -->
100
- <div id="panel-silhouette" role="tabpanel" aria-labelledby="tab-silhouette" class="p-6">
101
  <p class="text-sm text-gray-500 mb-4">
102
- Blue = inclusion strength, red = skipping strength per position
103
  </p>
104
- <div id="silhouette-container" class="w-full" style="min-height: 488px;">
105
- <iframe
106
- id="silhouette-iframe"
107
- src="/shiny/silhouette/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
108
- class="w-full border-0"
109
- style="height: 488px; min-height: 450px;"
110
- title="Silhouette View"
111
- ></iframe>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  </div>
113
  </div>
114
 
115
- <!-- Heatmap Panel (hidden by default) -->
116
- <div id="panel-heatmap" role="tabpanel" aria-labelledby="tab-heatmap" class="p-6 hidden">
 
 
 
 
 
 
117
  <p class="text-sm text-gray-500 mb-4">
118
- Blue = inclusion, red = skipping, with filter icons
119
  </p>
120
- <div id="heatmap-container" class="w-full" style="min-height: 675px;">
121
  <iframe
122
- id="heatmap-iframe"
123
- data-src="/shiny/heatmap/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
124
  class="w-full border-0"
125
- style="height: 675px; min-height: 638px;"
126
- title="Filter Activation Heatmap"
127
  ></iframe>
128
  </div>
129
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  </div>
131
 
132
  <!-- Actions -->
@@ -169,30 +361,39 @@
169
  {% endblock %}
170
 
171
  {% block scripts %}
 
172
  <script>
173
  const jobId = "{{ job_id }}";
174
  const batchIndex = {{ batch_index if batch_index is defined and batch_index is not none else 'null' }};
175
 
176
  // Tab state
177
- let activeTab = 'silhouette';
178
- let loadedTabs = new Set(['silhouette']);
 
 
 
 
 
 
 
 
 
 
179
 
180
  function switchTab(tabName) {
181
  if (tabName === activeTab) return;
182
 
183
- const tabs = ['silhouette', 'heatmap'];
184
  tabs.forEach(tab => {
185
  const button = document.getElementById(`tab-${tab}`);
186
  const panel = document.getElementById(`panel-${tab}`);
187
 
188
  if (tab === tabName) {
189
- // Activate
190
  button.classList.remove('border-transparent', 'text-gray-500');
191
  button.classList.add('border-primary-500', 'text-primary-600');
192
  button.setAttribute('aria-selected', 'true');
193
  panel.classList.remove('hidden');
194
  } else {
195
- // Deactivate
196
  button.classList.add('border-transparent', 'text-gray-500');
197
  button.classList.remove('border-primary-500', 'text-primary-600');
198
  button.setAttribute('aria-selected', 'false');
@@ -202,33 +403,28 @@
202
 
203
  activeTab = tabName;
204
 
205
- // Lazy load iframe if not yet loaded
206
- if (!loadedTabs.has(tabName)) {
207
- const iframe = document.getElementById(`${tabName}-iframe`);
208
- console.log(`[Result] Lazy loading ${tabName} iframe:`, iframe);
209
- if (iframe && iframe.dataset.src) {
210
- console.log(`[Result] Setting ${tabName} iframe src to:`, iframe.dataset.src);
211
- iframe.src = iframe.dataset.src;
212
- iframe.onload = function() {
213
- console.log(`[Result] ${tabName} iframe loaded, sending params...`);
214
- setTimeout(function() { sendParamsToIframe(iframe); }, 500);
215
- };
216
- iframe.onerror = function(e) {
217
- console.error(`[Result] ${tabName} iframe error:`, e);
218
- };
219
- }
220
- loadedTabs.add(tabName);
221
  }
222
  }
223
 
 
 
 
 
 
 
 
224
  // Send params to PyShiny iframes via postMessage
225
- function sendParamsToIframe(iframe) {
226
  if (iframe && iframe.contentWindow) {
227
- console.log('[Result] Sending params to iframe:', jobId, batchIndex);
228
  iframe.contentWindow.postMessage({
229
  type: 'setParams',
230
  job_id: jobId,
231
- batch_index: batchIndex
 
232
  }, '*');
233
  }
234
  }
@@ -237,7 +433,6 @@
237
  window.addEventListener('message', function(event) {
238
  if (event.data && event.data.type === 'ready') {
239
  console.log('[Result] Received ready from:', event.data.source);
240
- // Send params back to the iframe that signaled ready
241
  if (event.source) {
242
  event.source.postMessage({
243
  type: 'setParams',
@@ -248,17 +443,440 @@
248
  }
249
  });
250
 
251
- // Also send on iframe load (backup) - only for silhouette initially
252
- // Heatmap iframe will be set up when its tab is clicked (lazy loading)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  document.addEventListener('DOMContentLoaded', function() {
254
  const silhouetteIframe = document.getElementById('silhouette-iframe');
 
 
255
 
256
  if (silhouetteIframe) {
257
  silhouetteIframe.onload = function() {
258
- // Small delay to ensure Shiny is ready
259
  setTimeout(function() { sendParamsToIframe(silhouetteIframe); }, 500);
260
  };
261
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  });
263
  </script>
264
  <script src="/static/js/result.js"></script>
 
74
  <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
75
  <!-- Tab Navigation -->
76
  <div class="border-b border-gray-200">
77
+ <nav class="flex flex-wrap" role="tablist" aria-label="Visualization tabs">
78
  <button type="button"
79
+ id="tab-exon-view"
80
  role="tab"
81
  aria-selected="true"
82
+ aria-controls="panel-exon-view"
83
  class="px-6 py-3 text-sm font-medium border-b-2 border-primary-500 text-primary-600 focus:outline-none"
84
+ onclick="switchTab('exon-view')">
85
+ Exon View
86
  </button>
87
  <button type="button"
88
+ id="tab-feature-view"
89
  role="tab"
90
  aria-selected="false"
91
+ aria-controls="panel-feature-view"
92
  class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none"
93
+ onclick="switchTab('feature-view')">
94
+ Feature View
95
+ </button>
96
+ <button type="button"
97
+ id="tab-variant-analysis"
98
+ role="tab"
99
+ aria-selected="false"
100
+ aria-controls="panel-variant-analysis"
101
+ class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none"
102
+ onclick="switchTab('variant-analysis')">
103
+ Variant Analysis
104
+ </button>
105
+ <button type="button"
106
+ id="tab-download"
107
+ role="tab"
108
+ aria-selected="false"
109
+ aria-controls="panel-download"
110
+ class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none"
111
+ onclick="switchTab('download')">
112
+ Download
113
  </button>
114
  </nav>
115
  </div>
116
 
117
+ <!-- Exon View Panel -->
118
+ <div id="panel-exon-view" role="tabpanel" aria-labelledby="tab-exon-view" class="p-6">
119
  <p class="text-sm text-gray-500 mb-4">
120
+ Click on a panel to expand it. Use the "Download" tab to export visualizations as PNG.
121
  </p>
122
+
123
+ <!-- Silhouette Collapsible -->
124
+ <div class="collapsible-panel">
125
+ <div class="collapsible-header" onclick="toggleCollapsible(this)">
126
+ <h3>Silhouette View</h3>
127
+ <svg class="collapsible-chevron w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
128
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
129
+ </svg>
130
+ </div>
131
+ <div class="collapsible-content">
132
+ <p class="text-sm text-gray-500 mb-4">
133
+ Blue = inclusion strength, red = skipping strength per position.
134
+ </p>
135
+ <div id="silhouette-container" class="w-full" style="min-height: 488px;">
136
+ <iframe
137
+ id="silhouette-iframe"
138
+ src="/shiny/silhouette/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
139
+ class="w-full border-0"
140
+ style="height: 488px; min-height: 450px;"
141
+ title="Silhouette View"
142
+ ></iframe>
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Heatmap Collapsible -->
148
+ <div class="collapsible-panel">
149
+ <div class="collapsible-header" onclick="toggleCollapsible(this)">
150
+ <h3>Heat Map</h3>
151
+ <svg class="collapsible-chevron w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
152
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
153
+ </svg>
154
+ </div>
155
+ <div class="collapsible-content">
156
+ <p class="text-sm text-gray-500 mb-4">
157
+ Blue = inclusion, red = skipping, with filter icons.
158
+ </p>
159
+ <div id="heatmap-container" class="w-full" style="min-height: 675px;">
160
+ <iframe
161
+ id="heatmap-iframe"
162
+ src="/shiny/heatmap/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
163
+ class="w-full border-0"
164
+ style="height: 675px; min-height: 638px;"
165
+ title="Filter Activation Heatmap"
166
+ ></iframe>
167
+ </div>
168
+ </div>
169
  </div>
170
  </div>
171
 
172
+ <!-- Feature View Panel -->
173
+ <div id="panel-feature-view" role="tabpanel" aria-labelledby="tab-feature-view" class="p-6 hidden">
174
+ <div class="mb-4">
175
+ <label for="feature-selector" class="block text-sm font-medium text-gray-700 mb-2">Select Feature Filter</label>
176
+ <select id="feature-selector" class="w-full max-w-md px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500">
177
+ <option value="">Loading filters...</option>
178
+ </select>
179
+ </div>
180
  <p class="text-sm text-gray-500 mb-4">
181
+ Select a feature to view its individual contribution across all positions
182
  </p>
183
+ <div id="feature-silhouette-container" class="w-full" style="min-height: 488px;">
184
  <iframe
185
+ id="feature-silhouette-iframe"
186
+ src="/shiny/silhouette/?job_id={{ job_id }}{% if batch_index is defined and batch_index is not none %}&batch_index={{ batch_index }}{% endif %}"
187
  class="w-full border-0"
188
+ style="height: 488px; min-height: 450px;"
189
+ title="Feature Silhouette View"
190
  ></iframe>
191
  </div>
192
  </div>
193
+
194
+ <!-- Variant Analysis Panel -->
195
+ <div id="panel-variant-analysis" role="tabpanel" aria-labelledby="tab-variant-analysis" class="p-6 hidden">
196
+ <div id="variant-loading" class="variant-loading">
197
+ <svg class="h-10 w-10 text-primary-600" fill="none" viewBox="0 0 24 24">
198
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
199
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
200
+ </svg>
201
+ <p>Loading variant analysis...</p>
202
+ </div>
203
+
204
+ <div id="variant-not-run" class="hidden text-center py-8">
205
+ <svg class="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
206
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
207
+ </svg>
208
+ <h3 class="text-lg font-medium text-gray-900 mb-2">Variant Analysis Not Run</h3>
209
+ <p class="text-gray-500 mb-4">Run variant analysis to see how single-point mutations affect PSI prediction.</p>
210
+ <button onclick="runVariantAnalysis()" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
211
+ <svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
212
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
213
+ </svg>
214
+ Run Variant Analysis
215
+ </button>
216
+ <p class="text-xs text-gray-400 mt-2">This will analyze 210 mutations and may take ~45 seconds</p>
217
+ </div>
218
+
219
+ <div id="variant-content" class="hidden">
220
+ <!-- Summary Cards -->
221
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
222
+ <div class="bg-gray-50 rounded-lg p-4">
223
+ <p class="text-sm text-gray-500">Reference PSI</p>
224
+ <p id="variant-ref-psi" class="text-2xl font-bold text-gray-900">-</p>
225
+ </div>
226
+ <div class="bg-gray-50 rounded-lg p-4">
227
+ <p class="text-sm text-gray-500">Total Mutations</p>
228
+ <p id="variant-total-mutations" class="text-2xl font-bold text-gray-900">210</p>
229
+ </div>
230
+ <div class="bg-gray-50 rounded-lg p-4">
231
+ <p class="text-sm text-gray-500">Max |ΔPSI|</p>
232
+ <p id="variant-max-delta" class="text-2xl font-bold text-gray-900">-</p>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Top Mutations -->
237
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
238
+ <div>
239
+ <h4 class="text-sm font-semibold text-green-700 mb-3">Top Inclusion-Promoting Mutations</h4>
240
+ <div id="top-positive-mutations" class="space-y-2"></div>
241
+ </div>
242
+ <div>
243
+ <h4 class="text-sm font-semibold text-red-700 mb-3">Top Skipping-Promoting Mutations</h4>
244
+ <div id="top-negative-mutations" class="space-y-2"></div>
245
+ </div>
246
+ </div>
247
+
248
+ <!-- Variant Heatmap -->
249
+ <div class="mb-6">
250
+ <h4 class="text-sm font-semibold text-gray-700 mb-3">Variant Effect Heatmap</h4>
251
+ <p class="text-xs text-gray-500 mb-3">Shows ΔPSI for each possible single-nucleotide mutation. Gray cells indicate the reference nucleotide.</p>
252
+ <div id="variant-heatmap" style="min-height: 200px;"></div>
253
+ </div>
254
+
255
+ <!-- Mutations Table -->
256
+ <div>
257
+ <div class="flex items-center justify-between mb-3">
258
+ <h4 class="text-sm font-semibold text-gray-700">All Mutations</h4>
259
+ <input type="text" id="mutation-search" placeholder="Search mutations..." class="px-3 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500">
260
+ </div>
261
+ <div class="overflow-x-auto max-h-96 overflow-y-auto">
262
+ <table class="mutation-table">
263
+ <thead class="sticky top-0">
264
+ <tr>
265
+ <th class="cursor-pointer" onclick="sortMutationTable('position')">Position</th>
266
+ <th class="cursor-pointer" onclick="sortMutationTable('mutation_label')">Mutation</th>
267
+ <th class="cursor-pointer" onclick="sortMutationTable('psi')">PSI</th>
268
+ <th class="cursor-pointer" onclick="sortMutationTable('delta_psi')">ΔPSI</th>
269
+ </tr>
270
+ </thead>
271
+ <tbody id="mutations-table-body">
272
+ </tbody>
273
+ </table>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <!-- Download Panel -->
280
+ <div id="panel-download" role="tabpanel" aria-labelledby="tab-download" class="p-6 hidden">
281
+ <h3 class="text-lg font-medium text-gray-900 mb-4">Export Visualizations</h3>
282
+ <p class="text-sm text-gray-500 mb-6">Select the visualizations you want to download as PNG images.</p>
283
+
284
+ <div class="download-checkbox-group mb-6">
285
+ <label>
286
+ <input type="checkbox" id="download-silhouette" value="silhouette" checked>
287
+ <span>Silhouette View</span>
288
+ </label>
289
+ <label>
290
+ <input type="checkbox" id="download-heatmap" value="heatmap" checked>
291
+ <span>Filter Heat Map</span>
292
+ </label>
293
+ <label id="download-variant-option" class="hidden">
294
+ <input type="checkbox" id="download-variant" value="variant">
295
+ <span>Variant Effect Heatmap</span>
296
+ </label>
297
+ </div>
298
+
299
+ <div class="flex gap-3">
300
+ <button id="download-btn" onclick="downloadSelectedVisualizations()" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
301
+ <svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
302
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
303
+ </svg>
304
+ Download Selected (PNG)
305
+ </button>
306
+ </div>
307
+
308
+ <p id="download-status" class="text-sm text-gray-500 mt-4 hidden"></p>
309
+
310
+ <!-- CSV Export -->
311
+ <div class="mt-6 pt-6 border-t border-gray-200">
312
+ <h4 class="text-sm font-semibold text-gray-700 mb-3">Data Export</h4>
313
+ <p class="text-sm text-gray-500 mb-3">Download prediction results as CSV:</p>
314
+ <a href="/api/export/{{ job_id }}/csv" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
315
+ <svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
316
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
317
+ </svg>
318
+ Download CSV
319
+ </a>
320
+ </div>
321
+ </div>
322
  </div>
323
 
324
  <!-- Actions -->
 
361
  {% endblock %}
362
 
363
  {% block scripts %}
364
+ <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
365
  <script>
366
  const jobId = "{{ job_id }}";
367
  const batchIndex = {{ batch_index if batch_index is defined and batch_index is not none else 'null' }};
368
 
369
  // Tab state
370
+ let activeTab = 'exon-view';
371
+ let variantDataLoaded = false;
372
+ let variantData = null;
373
+ let mutationSortColumn = 'position';
374
+ let mutationSortAsc = true;
375
+
376
+ // Available filters for feature view
377
+ const availableFilters = [
378
+ 'incl_AG1', 'incl_AG2', 'incl_AG3', 'incl_M1', 'incl_M2', 'incl_M3',
379
+ 'skip_AG', 'skip_M1', 'skip_M2', 'skip_M3', 'skip_M4',
380
+ 'incl_struct', 'skip_struct_ALL'
381
+ ];
382
 
383
  function switchTab(tabName) {
384
  if (tabName === activeTab) return;
385
 
386
+ const tabs = ['exon-view', 'feature-view', 'variant-analysis', 'download'];
387
  tabs.forEach(tab => {
388
  const button = document.getElementById(`tab-${tab}`);
389
  const panel = document.getElementById(`panel-${tab}`);
390
 
391
  if (tab === tabName) {
 
392
  button.classList.remove('border-transparent', 'text-gray-500');
393
  button.classList.add('border-primary-500', 'text-primary-600');
394
  button.setAttribute('aria-selected', 'true');
395
  panel.classList.remove('hidden');
396
  } else {
 
397
  button.classList.add('border-transparent', 'text-gray-500');
398
  button.classList.remove('border-primary-500', 'text-primary-600');
399
  button.setAttribute('aria-selected', 'false');
 
403
 
404
  activeTab = tabName;
405
 
406
+ // Load variant analysis data when tab is first opened
407
+ if (tabName === 'variant-analysis' && !variantDataLoaded) {
408
+ loadVariantAnalysis();
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  }
410
  }
411
 
412
+ // Collapsible panel toggle
413
+ function toggleCollapsible(header) {
414
+ header.classList.toggle('expanded');
415
+ const content = header.nextElementSibling;
416
+ content.classList.toggle('expanded');
417
+ }
418
+
419
  // Send params to PyShiny iframes via postMessage
420
+ function sendParamsToIframe(iframe, filter = null) {
421
  if (iframe && iframe.contentWindow) {
422
+ console.log('[Result] Sending params to iframe:', jobId, batchIndex, filter);
423
  iframe.contentWindow.postMessage({
424
  type: 'setParams',
425
  job_id: jobId,
426
+ batch_index: batchIndex,
427
+ filter: filter
428
  }, '*');
429
  }
430
  }
 
433
  window.addEventListener('message', function(event) {
434
  if (event.data && event.data.type === 'ready') {
435
  console.log('[Result] Received ready from:', event.data.source);
 
436
  if (event.source) {
437
  event.source.postMessage({
438
  type: 'setParams',
 
443
  }
444
  });
445
 
446
+ // Initialize feature selector
447
+ function initFeatureSelector() {
448
+ const selector = document.getElementById('feature-selector');
449
+ selector.innerHTML = '<option value="">All Filters</option>';
450
+ availableFilters.forEach(filter => {
451
+ const option = document.createElement('option');
452
+ option.value = filter;
453
+ option.textContent = filter.replace(/_/g, ' ');
454
+ selector.appendChild(option);
455
+ });
456
+
457
+ selector.addEventListener('change', function() {
458
+ const featureIframe = document.getElementById('feature-silhouette-iframe');
459
+ sendParamsToIframe(featureIframe, this.value || null);
460
+ });
461
+ }
462
+
463
+ // Load variant analysis data
464
+ async function loadVariantAnalysis() {
465
+ const loading = document.getElementById('variant-loading');
466
+ const notRun = document.getElementById('variant-not-run');
467
+ const content = document.getElementById('variant-content');
468
+
469
+ loading.classList.remove('hidden');
470
+ notRun.classList.add('hidden');
471
+ content.classList.add('hidden');
472
+
473
+ try {
474
+ const url = batchIndex !== null
475
+ ? `/api/result/${jobId}/mutagenesis?batch_index=${batchIndex}`
476
+ : `/api/result/${jobId}/mutagenesis`;
477
+
478
+ const response = await fetch(url);
479
+ const data = await response.json();
480
+
481
+ if (data.status === 'not_run') {
482
+ loading.classList.add('hidden');
483
+ notRun.classList.remove('hidden');
484
+ return;
485
+ }
486
+
487
+ variantData = data;
488
+ variantDataLoaded = true;
489
+ displayVariantAnalysis(data);
490
+
491
+ loading.classList.add('hidden');
492
+ content.classList.remove('hidden');
493
+
494
+ // Show variant download option in Download tab
495
+ document.getElementById('download-variant-option').classList.remove('hidden');
496
+
497
+ } catch (error) {
498
+ console.error('Error loading variant analysis:', error);
499
+ loading.classList.add('hidden');
500
+ notRun.classList.remove('hidden');
501
+ }
502
+ }
503
+
504
+ // Run variant analysis on demand
505
+ async function runVariantAnalysis() {
506
+ const loading = document.getElementById('variant-loading');
507
+ const notRun = document.getElementById('variant-not-run');
508
+ const content = document.getElementById('variant-content');
509
+
510
+ loading.classList.remove('hidden');
511
+ notRun.classList.add('hidden');
512
+ loading.querySelector('p').textContent = 'Running variant analysis (this may take ~45 seconds)...';
513
+
514
+ try {
515
+ const url = batchIndex !== null
516
+ ? `/api/result/${jobId}/mutagenesis?batch_index=${batchIndex}`
517
+ : `/api/result/${jobId}/mutagenesis`;
518
+
519
+ const response = await fetch(url, { method: 'POST' });
520
+ const data = await response.json();
521
+
522
+ variantData = data;
523
+ variantDataLoaded = true;
524
+ displayVariantAnalysis(data);
525
+
526
+ loading.classList.add('hidden');
527
+ content.classList.remove('hidden');
528
+
529
+ // Show variant download option in Download tab
530
+ document.getElementById('download-variant-option').classList.remove('hidden');
531
+
532
+ } catch (error) {
533
+ console.error('Error running variant analysis:', error);
534
+ loading.classList.add('hidden');
535
+ notRun.classList.remove('hidden');
536
+ alert('Failed to run variant analysis. Please try again.');
537
+ }
538
+ }
539
+
540
+ // Display variant analysis results
541
+ function displayVariantAnalysis(data) {
542
+ // Summary
543
+ document.getElementById('variant-ref-psi').textContent = data.reference_psi?.toFixed(3) || '-';
544
+ document.getElementById('variant-total-mutations').textContent = data.total_mutations || 210;
545
+
546
+ // Calculate max delta
547
+ const maxDelta = data.mutations?.reduce((max, m) => {
548
+ const delta = Math.abs(m.delta_psi || 0);
549
+ return delta > max ? delta : max;
550
+ }, 0) || 0;
551
+ document.getElementById('variant-max-delta').textContent = maxDelta.toFixed(3);
552
+
553
+ // Top positive mutations
554
+ const topPositive = document.getElementById('top-positive-mutations');
555
+ topPositive.innerHTML = (data.top_positive || []).slice(0, 5).map(m => `
556
+ <div class="flex justify-between items-center p-2 bg-green-50 rounded text-sm">
557
+ <span class="font-mono">${m.mutation_label}</span>
558
+ <span class="delta-positive">+${m.delta_psi?.toFixed(3) || '0.000'}</span>
559
+ </div>
560
+ `).join('');
561
+
562
+ // Top negative mutations
563
+ const topNegative = document.getElementById('top-negative-mutations');
564
+ topNegative.innerHTML = (data.top_negative || []).slice(0, 5).map(m => `
565
+ <div class="flex justify-between items-center p-2 bg-red-50 rounded text-sm">
566
+ <span class="font-mono">${m.mutation_label}</span>
567
+ <span class="delta-negative">${m.delta_psi?.toFixed(3) || '0.000'}</span>
568
+ </div>
569
+ `).join('');
570
+
571
+ // Render heatmap
572
+ renderVariantHeatmap(data.heatmap_data);
573
+
574
+ // Populate mutations table
575
+ populateMutationsTable(data.mutations || []);
576
+
577
+ // Set up search
578
+ document.getElementById('mutation-search').addEventListener('input', function() {
579
+ filterMutationsTable(this.value);
580
+ });
581
+ }
582
+
583
+ // Download state
584
+ let pendingDownloads = {};
585
+ let downloadResults = {};
586
+
587
+ // Listen for download responses from iframes
588
+ window.addEventListener('message', function(event) {
589
+ if (event.data && event.data.type === 'downloadResponse') {
590
+ const source = event.data.source;
591
+ console.log('[Download] Received response from:', source);
592
+ downloadResults[source] = event.data;
593
+
594
+ // Check if all pending downloads are complete
595
+ checkDownloadsComplete();
596
+ }
597
+ });
598
+
599
+ // Request download from an iframe
600
+ function requestIframeDownload(iframeId, source) {
601
+ const iframe = document.getElementById(iframeId);
602
+ if (iframe && iframe.contentWindow) {
603
+ pendingDownloads[source] = true;
604
+ iframe.contentWindow.postMessage({ type: 'downloadRequest' }, '*');
605
+ }
606
+ }
607
+
608
+ // Check if all downloads are complete and trigger save
609
+ function checkDownloadsComplete() {
610
+ const pendingKeys = Object.keys(pendingDownloads);
611
+ const completedKeys = Object.keys(downloadResults);
612
+
613
+ if (pendingKeys.every(key => completedKeys.includes(key))) {
614
+ // All downloads complete
615
+ saveDownloadedFiles();
616
+ }
617
+ }
618
+
619
+ // Save downloaded files
620
+ function saveDownloadedFiles() {
621
+ const files = [];
622
+
623
+ for (const [source, result] of Object.entries(downloadResults)) {
624
+ if (result.dataUrl && !result.error) {
625
+ files.push({
626
+ name: `${source}_${jobId}.png`,
627
+ dataUrl: result.dataUrl
628
+ });
629
+ }
630
+ }
631
+
632
+ // Reset state
633
+ pendingDownloads = {};
634
+ downloadResults = {};
635
+
636
+ // Update UI
637
+ const btn = document.getElementById('download-btn');
638
+ const status = document.getElementById('download-status');
639
+ btn.disabled = false;
640
+ btn.innerHTML = `<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>Download Selected (PNG)`;
641
+
642
+ if (files.length === 0) {
643
+ status.textContent = 'No visualizations could be downloaded. Make sure the visualizations have loaded.';
644
+ status.classList.remove('hidden');
645
+ return;
646
+ }
647
+
648
+ // Download files
649
+ files.forEach((file, index) => {
650
+ setTimeout(() => {
651
+ const link = document.createElement('a');
652
+ link.href = file.dataUrl;
653
+ link.download = file.name;
654
+ document.body.appendChild(link);
655
+ link.click();
656
+ document.body.removeChild(link);
657
+ }, index * 500); // Stagger downloads
658
+ });
659
+
660
+ status.textContent = `Downloaded ${files.length} file(s).`;
661
+ status.classList.remove('hidden');
662
+ setTimeout(() => status.classList.add('hidden'), 3000);
663
+ }
664
+
665
+ // Download selected visualizations
666
+ function downloadSelectedVisualizations() {
667
+ const checkboxes = document.querySelectorAll('.download-checkbox-group input[type="checkbox"]:checked');
668
+ const selected = Array.from(checkboxes).map(cb => cb.value);
669
+
670
+ if (selected.length === 0) {
671
+ alert('Please select at least one visualization to download.');
672
+ return;
673
+ }
674
+
675
+ // Reset state
676
+ pendingDownloads = {};
677
+ downloadResults = {};
678
+
679
+ // Update UI
680
+ const btn = document.getElementById('download-btn');
681
+ const status = document.getElementById('download-status');
682
+ btn.disabled = true;
683
+ btn.innerHTML = `<svg class="animate-spin mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>Preparing...`;
684
+ status.classList.add('hidden');
685
+
686
+ // Request downloads
687
+ if (selected.includes('silhouette')) {
688
+ requestIframeDownload('silhouette-iframe', 'silhouette');
689
+ }
690
+ if (selected.includes('heatmap')) {
691
+ requestIframeDownload('heatmap-iframe', 'heatmap');
692
+ }
693
+ if (selected.includes('variant')) {
694
+ // Variant heatmap is on the same page, capture directly
695
+ const plotDiv = document.getElementById('variant-heatmap');
696
+ if (plotDiv && plotDiv.data) {
697
+ Plotly.toImage(plotDiv, { format: 'png', width: 1400, height: 300, scale: 2 })
698
+ .then(dataUrl => {
699
+ downloadResults['variant'] = { dataUrl: dataUrl };
700
+ pendingDownloads['variant'] = true;
701
+ checkDownloadsComplete();
702
+ });
703
+ pendingDownloads['variant'] = true;
704
+ }
705
+ }
706
+
707
+ // Timeout after 10 seconds
708
+ setTimeout(() => {
709
+ if (Object.keys(pendingDownloads).length > Object.keys(downloadResults).length) {
710
+ saveDownloadedFiles(); // Save what we have
711
+ }
712
+ }, 10000);
713
+ }
714
+
715
+ // Download variant heatmap as PNG (standalone)
716
+ function downloadVariantHeatmap() {
717
+ const plotDiv = document.getElementById('variant-heatmap');
718
+ if (plotDiv && plotDiv.data) {
719
+ Plotly.downloadImage(plotDiv, {
720
+ format: 'png',
721
+ width: 1400,
722
+ height: 300,
723
+ filename: `variant_heatmap_${jobId}`
724
+ });
725
+ } else {
726
+ alert('Variant heatmap not available. Please run variant analysis first.');
727
+ }
728
+ }
729
+
730
+ // Render variant effect heatmap using Plotly
731
+ function renderVariantHeatmap(heatmapData) {
732
+ if (!heatmapData) return;
733
+
734
+ const nucleotides = ['T', 'G', 'C', 'A'];
735
+ const positions = heatmapData.positions || Array.from({length: 70}, (_, i) => i + 1);
736
+
737
+ // Build z-values matrix (4 rows for A, C, G, T mutations TO)
738
+ const zValues = nucleotides.map(nt => heatmapData.delta_matrix[nt] || []);
739
+
740
+ // Custom text for hover
741
+ const hoverText = nucleotides.map((toNt, rowIdx) =>
742
+ positions.map((pos, colIdx) => {
743
+ const delta = zValues[rowIdx][colIdx];
744
+ if (delta === null) return `Pos ${pos}: Reference (${toNt})`;
745
+ return `Pos ${pos}: →${toNt}<br>ΔPSI: ${delta?.toFixed(3) || 'N/A'}`;
746
+ })
747
+ );
748
+
749
+ // Find max absolute value for symmetric color scale
750
+ const allValues = zValues.flat().filter(v => v !== null);
751
+ const maxAbs = Math.max(...allValues.map(Math.abs), 0.01);
752
+
753
+ const trace = {
754
+ z: zValues,
755
+ x: positions,
756
+ y: nucleotides,
757
+ type: 'heatmap',
758
+ colorscale: [
759
+ [0, '#dc2626'],
760
+ [0.5, '#ffffff'],
761
+ [1, '#2563eb']
762
+ ],
763
+ zmin: -maxAbs,
764
+ zmax: maxAbs,
765
+ text: hoverText,
766
+ hovertemplate: '%{text}<extra></extra>',
767
+ colorbar: {
768
+ title: 'ΔPSI',
769
+ titleside: 'right'
770
+ }
771
+ };
772
+
773
+ const layout = {
774
+ margin: { t: 40, r: 60, b: 40, l: 40 },
775
+ xaxis: {
776
+ title: 'Position',
777
+ tickmode: 'linear',
778
+ dtick: 5
779
+ },
780
+ yaxis: {
781
+ title: 'Mutation To',
782
+ tickfont: { family: 'monospace' }
783
+ },
784
+ height: 200
785
+ };
786
+
787
+ Plotly.newPlot('variant-heatmap', [trace], layout, { responsive: true });
788
+ }
789
+
790
+ // Populate mutations table
791
+ function populateMutationsTable(mutations) {
792
+ const tbody = document.getElementById('mutations-table-body');
793
+ tbody.innerHTML = mutations.map(m => `
794
+ <tr data-position="${m.position}" data-label="${m.mutation_label}" data-psi="${m.psi || 0}" data-delta="${m.delta_psi || 0}">
795
+ <td>${m.position}</td>
796
+ <td class="font-mono">${m.mutation_label}</td>
797
+ <td>${m.psi?.toFixed(3) || '-'}</td>
798
+ <td class="${(m.delta_psi || 0) > 0 ? 'delta-positive' : (m.delta_psi || 0) < 0 ? 'delta-negative' : ''}">${m.delta_psi?.toFixed(3) || '-'}</td>
799
+ </tr>
800
+ `).join('');
801
+ }
802
+
803
+ // Sort mutations table
804
+ function sortMutationTable(column) {
805
+ if (mutationSortColumn === column) {
806
+ mutationSortAsc = !mutationSortAsc;
807
+ } else {
808
+ mutationSortColumn = column;
809
+ mutationSortAsc = true;
810
+ }
811
+
812
+ const tbody = document.getElementById('mutations-table-body');
813
+ const rows = Array.from(tbody.querySelectorAll('tr'));
814
+
815
+ rows.sort((a, b) => {
816
+ let aVal, bVal;
817
+ if (column === 'position') {
818
+ aVal = parseInt(a.dataset.position);
819
+ bVal = parseInt(b.dataset.position);
820
+ } else if (column === 'mutation_label') {
821
+ aVal = a.dataset.label;
822
+ bVal = b.dataset.label;
823
+ } else if (column === 'psi') {
824
+ aVal = parseFloat(a.dataset.psi);
825
+ bVal = parseFloat(b.dataset.psi);
826
+ } else if (column === 'delta_psi') {
827
+ aVal = parseFloat(a.dataset.delta);
828
+ bVal = parseFloat(b.dataset.delta);
829
+ }
830
+
831
+ if (typeof aVal === 'string') {
832
+ return mutationSortAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
833
+ }
834
+ return mutationSortAsc ? aVal - bVal : bVal - aVal;
835
+ });
836
+
837
+ rows.forEach(row => tbody.appendChild(row));
838
+ }
839
+
840
+ // Filter mutations table
841
+ function filterMutationsTable(search) {
842
+ const tbody = document.getElementById('mutations-table-body');
843
+ const rows = tbody.querySelectorAll('tr');
844
+ const searchLower = search.toLowerCase();
845
+
846
+ rows.forEach(row => {
847
+ const label = row.dataset.label.toLowerCase();
848
+ const position = row.dataset.position;
849
+ const visible = label.includes(searchLower) || position.includes(searchLower);
850
+ row.style.display = visible ? '' : 'none';
851
+ });
852
+ }
853
+
854
+ // Set up iframe onload handlers
855
  document.addEventListener('DOMContentLoaded', function() {
856
  const silhouetteIframe = document.getElementById('silhouette-iframe');
857
+ const heatmapIframe = document.getElementById('heatmap-iframe');
858
+ const featureIframe = document.getElementById('feature-silhouette-iframe');
859
 
860
  if (silhouetteIframe) {
861
  silhouetteIframe.onload = function() {
 
862
  setTimeout(function() { sendParamsToIframe(silhouetteIframe); }, 500);
863
  };
864
  }
865
+
866
+ if (heatmapIframe) {
867
+ heatmapIframe.onload = function() {
868
+ setTimeout(function() { sendParamsToIframe(heatmapIframe); }, 500);
869
+ };
870
+ }
871
+
872
+ if (featureIframe) {
873
+ featureIframe.onload = function() {
874
+ setTimeout(function() { sendParamsToIframe(featureIframe); }, 500);
875
+ };
876
+ }
877
+
878
+ // Initialize feature selector
879
+ initFeatureSelector();
880
  });
881
  </script>
882
  <script src="/static/js/result.js"></script>