chyams Claude Opus 4.6 commited on
Commit
2378688
Β·
1 Parent(s): 2fa27b0

Embedding Explorer: add visibility toggles, camera persistence, and share URLs

Browse files

- Visibility CheckboxGroup to show/hide items without recomputing MDS
(for progressive slide screenshots)
- Camera persistence across re-renders via JS polling of Plotly camera
state into hidden textbox
- Share button encodes input, visibility, camera into URL params
(Rebrandly shortening on HF Spaces, full URL on localhost)
- Share URL loading restores full state including camera angle
- Loading flag suppresses cascading Gradio events during share restore
- Fixed Gradio 6.x: moved theme/css/head to launch(), buttons=["copy"]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +370 -33
app.py CHANGED
@@ -15,6 +15,8 @@ import os
15
  import json
16
  import re
17
  import warnings
 
 
18
 
19
  import pandas # noqa: F401 β€” import before plotly to avoid circular import
20
  import numpy as np
@@ -36,6 +38,16 @@ EXAMPLES = json.loads(os.environ.get("EXAMPLES", json.dumps([
36
 
37
  N_NEIGHBORS = int(os.environ.get("N_NEIGHBORS", "8"))
38
 
 
 
 
 
 
 
 
 
 
 
39
  # ── Course design system colors ──────────────────────────────
40
 
41
  PURPLE = "#63348d"
@@ -247,20 +259,21 @@ def _axis(title=""):
247
  )
248
 
249
 
250
- def layout_3d(height=700, axis_range=1.3):
251
- """Shared Plotly 3D layout. Preserves camera between updates."""
252
  ax_x, ax_y, ax_z = _axis(), _axis(), _axis()
253
  fixed = [-axis_range, axis_range]
254
  ax_x["range"] = fixed
255
  ax_y["range"] = fixed
256
  ax_z["range"] = fixed
 
257
  return dict(
258
  scene=dict(
259
  xaxis=ax_x,
260
  yaxis=ax_y,
261
  zaxis=ax_z,
262
  bgcolor="white",
263
- camera=dict(eye=dict(x=1.5, y=1.5, z=1.2)),
264
  aspectmode="cube",
265
  ),
266
  paper_bgcolor="white",
@@ -273,6 +286,7 @@ def layout_3d(height=700, axis_range=1.3):
273
  ),
274
  height=height,
275
  font=dict(family="Inter, sans-serif"),
 
276
  )
277
 
278
 
@@ -393,15 +407,86 @@ def blank(msg):
393
  return fig
394
 
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  # ── Main visualization ───────────────────────────────────────
397
 
398
- def explore(input_text, selected):
399
  """Unified 3D visualization of words and vector expressions.
400
 
401
- Expressions show geometric construction:
402
- a - b β†’ operand arrows from origin + gold arrow from b to a
403
- a + b β†’ a from origin, b from a's tip, gold result from origin
404
- a - b + c β†’ chain arrows + gold result from origin
 
 
 
 
405
  """
406
 
407
  if not input_text or not input_text.strip():
@@ -409,6 +494,7 @@ def explore(input_text, selected):
409
  blank("Enter words or expressions above to visualize in 3D"),
410
  "",
411
  gr.update(choices=[], value=None, visible=False),
 
412
  )
413
 
414
  items, bad = parse_items(input_text)
@@ -417,13 +503,17 @@ def explore(input_text, selected):
417
  msg = "No valid items found."
418
  if bad:
419
  msg += f"<br>Not in vocabulary: {', '.join(bad)}"
420
- return blank(msg), "", gr.update(choices=[], value=None, visible=False)
421
 
422
  items = items[:12]
423
  labels = [item[0] for item in items]
 
 
 
 
424
 
425
  # No auto-select β€” user clicks radio to see neighbors
426
- if selected and selected != "(clear)" and selected in labels:
427
  sel_idx = labels.index(selected)
428
  else:
429
  selected = None
@@ -476,7 +566,7 @@ def explore(input_text, selected):
476
  if len(all_words) >= 3:
477
  break
478
 
479
- # Gather neighbors if something is selected
480
  nbr_data = []
481
  if selected is not None:
482
  sel_item = items[sel_idx]
@@ -496,7 +586,7 @@ def explore(input_text, selected):
496
  mds_words = all_words + [w for w, _ in nbr_data]
497
  if not mds_words:
498
  return blank("No valid words found."), "", gr.update(
499
- choices=[], value=None, visible=False)
500
 
501
  mds_vecs = np.array([model[w] for w in mds_words])
502
  mds_coords = reduce_3d(mds_vecs)
@@ -536,7 +626,7 @@ def explore(input_text, selected):
536
  extra_points.append(cursor.copy())
537
  expr_info[label] = ('chain', chain, cursor.copy())
538
 
539
- # ── Dynamic axis range ──
540
  all_rendered = [word_3d[w] for w in all_words] + extra_points
541
  if nbr_coords is not None:
542
  all_rendered.extend(nbr_coords)
@@ -569,6 +659,10 @@ def explore(input_text, selected):
569
  ))
570
 
571
  for idx, (label, vec, is_expr, ops, ordered) in enumerate(items):
 
 
 
 
572
  color = item_colors[idx]
573
  is_sel = (sel_idx is not None and idx == sel_idx)
574
  is_dim = (sel_idx is not None and idx != sel_idx)
@@ -701,31 +795,37 @@ def explore(input_text, selected):
701
  add_label(nbr_coords[i, 0], nbr_coords[i, 1], nbr_coords[i, 2],
702
  w, size=16, color=DARK)
703
 
704
- fig.update_layout(**layout_3d(axis_range=axis_range),
705
  scene_annotations=annotations)
706
 
707
  # ── Status text ──
708
- n_words = sum(1 for _, _, ie, _, _ in items if not ie)
709
- n_expr = sum(1 for _, _, ie, _, _ in items if ie)
 
 
710
  parts = []
711
  if n_words:
712
  parts.append(f"**{n_words} word{'s' if n_words != 1 else ''}**")
713
  if n_expr:
714
  parts.append(f"**{n_expr} expression{'s' if n_expr != 1 else ''}**")
715
- status = " + ".join(parts) + " in 3D"
 
 
716
  if bad:
717
  status += f" Β· Not found: {', '.join(bad)}"
718
  for label in expr_nearest:
719
- w, s = expr_nearest[label]
720
- status += f" Β· **{label} β‰ˆ {w}** ({s:.3f})"
 
721
  if nbr_data:
722
  status += f" Β· {len(nbr_data)} neighbors of **{selected}**"
723
 
724
- choices = ["(clear)"] + labels
725
  return (
726
  fig,
727
  status,
728
  gr.update(choices=choices, value=selected or "(clear)", visible=True),
 
729
  )
730
 
731
 
@@ -760,13 +860,77 @@ h1 { color: #63348d !important; }
760
  color: #ffffff !important;
761
  }
762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  /* Input fields β€” white for contrast */
764
  textarea, input[type="text"] {
765
  background: #ffffff !important;
766
  }
767
  """
768
 
769
- FORCE_LIGHT = '<script>if(!location.search.includes("__theme=light")){const u=new URL(location);u.searchParams.set("__theme","light");location.replace(u)}</script>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
770
 
771
  _LIGHT = {
772
  "button_primary_background_fill": "#63348d",
@@ -800,9 +964,15 @@ THEME = gr.themes.Soft(
800
  font=gr.themes.GoogleFont("Inter"),
801
  ).set(**_ALL)
802
 
803
- with gr.Blocks(title="Embedding Explorer", theme=THEME, css=CSS, head=FORCE_LIGHT) as demo:
804
 
805
- # Force light mode β€” head param doesn't execute on HF Spaces
 
 
 
 
 
 
806
  gr.HTML('<script>if(!location.search.includes("__theme=light"))'
807
  '{const u=new URL(location);u.searchParams.set("__theme","light");'
808
  'location.replace(u)}</script>')
@@ -830,7 +1000,12 @@ with gr.Blocks(title="Embedding Explorer", theme=THEME, css=CSS, head=FORCE_LIGH
830
  lines=1,
831
  )
832
  with gr.Column(scale=1):
833
- exp_btn = gr.Button("Explore", variant="primary")
 
 
 
 
 
834
  with gr.Column(elem_classes=["purple-examples"]):
835
  gr.Examples(
836
  examples=[[e] for e in EXAMPLES],
@@ -839,6 +1014,12 @@ with gr.Blocks(title="Embedding Explorer", theme=THEME, css=CSS, head=FORCE_LIGH
839
  )
840
  exp_plot = gr.Plot(label="Embedding Space")
841
  exp_status = gr.Markdown("")
 
 
 
 
 
 
842
  exp_radio = gr.Radio(
843
  label="Click to see nearest neighbors",
844
  choices=[], value=None,
@@ -846,21 +1027,177 @@ with gr.Blocks(title="Embedding Explorer", theme=THEME, css=CSS, head=FORCE_LIGH
846
  elem_classes=["nbr-radio"],
847
  )
848
 
849
- # Events
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  exp_btn.click(
851
- lambda w: explore(w, None),
852
  inputs=[exp_in],
853
- outputs=[exp_plot, exp_status, exp_radio],
854
  )
855
  exp_in.submit(
856
- lambda w: explore(w, None),
857
  inputs=[exp_in],
858
- outputs=[exp_plot, exp_status, exp_radio],
859
  )
 
860
  exp_radio.change(
861
- explore,
862
- inputs=[exp_in, exp_radio],
863
- outputs=[exp_plot, exp_status, exp_radio],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
864
  )
865
 
866
- demo.launch()
 
15
  import json
16
  import re
17
  import warnings
18
+ import urllib.parse
19
+ import subprocess
20
 
21
  import pandas # noqa: F401 β€” import before plotly to avoid circular import
22
  import numpy as np
 
38
 
39
  N_NEIGHBORS = int(os.environ.get("N_NEIGHBORS", "8"))
40
 
41
+ # ── Share URL infrastructure ─────────────────────────────────
42
+
43
+ REBRANDLY_API_KEY = os.environ.get("REBRANDLY_API_KEY", "")
44
+ _SPACE_ID = os.environ.get("SPACE_ID", "")
45
+ if _SPACE_ID:
46
+ _owner, _name = _SPACE_ID.split("/")
47
+ _BASE_URL = f"https://{_owner}-{_name}.hf.space/"
48
+ else:
49
+ _BASE_URL = "http://localhost:7860/"
50
+
51
  # ── Course design system colors ──────────────────────────────
52
 
53
  PURPLE = "#63348d"
 
259
  )
260
 
261
 
262
+ def layout_3d(height=700, axis_range=1.3, camera=None):
263
+ """Shared Plotly 3D layout. Uses uirevision to preserve camera across updates."""
264
  ax_x, ax_y, ax_z = _axis(), _axis(), _axis()
265
  fixed = [-axis_range, axis_range]
266
  ax_x["range"] = fixed
267
  ax_y["range"] = fixed
268
  ax_z["range"] = fixed
269
+ default_camera = dict(eye=dict(x=1.5, y=1.5, z=1.2))
270
  return dict(
271
  scene=dict(
272
  xaxis=ax_x,
273
  yaxis=ax_y,
274
  zaxis=ax_z,
275
  bgcolor="white",
276
+ camera=camera or default_camera,
277
  aspectmode="cube",
278
  ),
279
  paper_bgcolor="white",
 
286
  ),
287
  height=height,
288
  font=dict(family="Inter, sans-serif"),
289
+ uirevision="keep",
290
  )
291
 
292
 
 
407
  return fig
408
 
409
 
410
+ # ── Share URL helpers ────────────────────────────────────────
411
+
412
+ def _shorten_url(long_url):
413
+ """Shorten a URL via Rebrandly API (using curl). Falls back to long URL."""
414
+ if not REBRANDLY_API_KEY or "localhost" in long_url:
415
+ return long_url
416
+ try:
417
+ payload = json.dumps({
418
+ "destination": long_url,
419
+ "domain": {"fullName": "go.ropavieja.org"},
420
+ })
421
+ result = subprocess.run(
422
+ [
423
+ "curl", "-s", "-X", "POST",
424
+ "https://api.rebrandly.com/v1/links",
425
+ "-H", "Content-Type: application/json",
426
+ "-H", f"apikey: {REBRANDLY_API_KEY}",
427
+ "-d", payload,
428
+ ],
429
+ capture_output=True, text=True, timeout=10,
430
+ )
431
+ if result.returncode != 0 or not result.stdout.strip():
432
+ return long_url
433
+ data = json.loads(result.stdout)
434
+ return f"https://{data['shortUrl']}"
435
+ except (subprocess.TimeoutExpired, KeyError, json.JSONDecodeError, OSError) as exc:
436
+ print(f"[share] Rebrandly error: {exc}")
437
+ return long_url
438
+
439
+
440
+ def _parse_camera(cam_str):
441
+ """Parse compact camera string (ex,ey,ez[,cx,cy,cz,ux,uy,uz]) to Plotly camera dict."""
442
+ if not cam_str:
443
+ return None
444
+ try:
445
+ vals = [float(v) for v in cam_str.split(",")]
446
+ if len(vals) >= 3:
447
+ camera = dict(eye=dict(x=vals[0], y=vals[1], z=vals[2]))
448
+ if len(vals) >= 6:
449
+ camera["center"] = dict(x=vals[3], y=vals[4], z=vals[5])
450
+ if len(vals) >= 9:
451
+ camera["up"] = dict(x=vals[6], y=vals[7], z=vals[8])
452
+ return camera
453
+ except (ValueError, IndexError):
454
+ pass
455
+ return None
456
+
457
+
458
+ def _encode_camera(camera_json):
459
+ """Encode Plotly camera JSON to compact string for URL params."""
460
+ if not camera_json:
461
+ return ""
462
+ try:
463
+ cam = json.loads(camera_json)
464
+ eye = cam.get("eye", {})
465
+ center = cam.get("center", {})
466
+ up = cam.get("up", {})
467
+ vals = [
468
+ eye.get("x", 1.5), eye.get("y", 1.5), eye.get("z", 1.2),
469
+ center.get("x", 0), center.get("y", 0), center.get("z", 0),
470
+ up.get("x", 0), up.get("y", 0), up.get("z", 1),
471
+ ]
472
+ return ",".join(f"{v:.2f}" for v in vals)
473
+ except (json.JSONDecodeError, TypeError):
474
+ return ""
475
+
476
+
477
  # ── Main visualization ───────────────────────────────────────
478
 
479
+ def explore(input_text, selected, hidden=None, camera=None):
480
  """Unified 3D visualization of words and vector expressions.
481
 
482
+ Args:
483
+ input_text: Comma-separated words and/or expressions.
484
+ selected: Currently selected item for neighbor display (or None).
485
+ hidden: Set of labels to hide from rendering (MDS still uses all items).
486
+ camera: Plotly camera dict to set initial view.
487
+
488
+ Returns:
489
+ (fig, status_md, radio_update, all_labels)
490
  """
491
 
492
  if not input_text or not input_text.strip():
 
494
  blank("Enter words or expressions above to visualize in 3D"),
495
  "",
496
  gr.update(choices=[], value=None, visible=False),
497
+ [],
498
  )
499
 
500
  items, bad = parse_items(input_text)
 
503
  msg = "No valid items found."
504
  if bad:
505
  msg += f"<br>Not in vocabulary: {', '.join(bad)}"
506
+ return blank(msg), "", gr.update(choices=[], value=None, visible=False), []
507
 
508
  items = items[:12]
509
  labels = [item[0] for item in items]
510
+ hidden = hidden or set()
511
+
512
+ # Visible labels for radio choices (exclude hidden)
513
+ visible_labels = [l for l in labels if l not in hidden]
514
 
515
  # No auto-select β€” user clicks radio to see neighbors
516
+ if selected and selected != "(clear)" and selected in visible_labels:
517
  sel_idx = labels.index(selected)
518
  else:
519
  selected = None
 
566
  if len(all_words) >= 3:
567
  break
568
 
569
+ # Gather neighbors if something is selected (and not hidden)
570
  nbr_data = []
571
  if selected is not None:
572
  sel_item = items[sel_idx]
 
586
  mds_words = all_words + [w for w, _ in nbr_data]
587
  if not mds_words:
588
  return blank("No valid words found."), "", gr.update(
589
+ choices=[], value=None, visible=False), []
590
 
591
  mds_vecs = np.array([model[w] for w in mds_words])
592
  mds_coords = reduce_3d(mds_vecs)
 
626
  extra_points.append(cursor.copy())
627
  expr_info[label] = ('chain', chain, cursor.copy())
628
 
629
+ # ── Dynamic axis range (computed from ALL items, not just visible) ──
630
  all_rendered = [word_3d[w] for w in all_words] + extra_points
631
  if nbr_coords is not None:
632
  all_rendered.extend(nbr_coords)
 
659
  ))
660
 
661
  for idx, (label, vec, is_expr, ops, ordered) in enumerate(items):
662
+ # Skip hidden items
663
+ if label in hidden:
664
+ continue
665
+
666
  color = item_colors[idx]
667
  is_sel = (sel_idx is not None and idx == sel_idx)
668
  is_dim = (sel_idx is not None and idx != sel_idx)
 
795
  add_label(nbr_coords[i, 0], nbr_coords[i, 1], nbr_coords[i, 2],
796
  w, size=16, color=DARK)
797
 
798
+ fig.update_layout(**layout_3d(axis_range=axis_range, camera=camera),
799
  scene_annotations=annotations)
800
 
801
  # ── Status text ──
802
+ n_visible = sum(1 for l, _, ie, _, _ in items if l not in hidden)
803
+ n_hidden = len(hidden & set(labels))
804
+ n_words = sum(1 for l, _, ie, _, _ in items if not ie and l not in hidden)
805
+ n_expr = sum(1 for l, _, ie, _, _ in items if ie and l not in hidden)
806
  parts = []
807
  if n_words:
808
  parts.append(f"**{n_words} word{'s' if n_words != 1 else ''}**")
809
  if n_expr:
810
  parts.append(f"**{n_expr} expression{'s' if n_expr != 1 else ''}**")
811
+ status = " + ".join(parts) + " in 3D" if parts else "Nothing visible"
812
+ if n_hidden:
813
+ status += f" Β· {n_hidden} hidden"
814
  if bad:
815
  status += f" Β· Not found: {', '.join(bad)}"
816
  for label in expr_nearest:
817
+ if label not in hidden:
818
+ w, s = expr_nearest[label]
819
+ status += f" Β· **{label} β‰ˆ {w}** ({s:.3f})"
820
  if nbr_data:
821
  status += f" Β· {len(nbr_data)} neighbors of **{selected}**"
822
 
823
+ choices = ["(clear)"] + visible_labels
824
  return (
825
  fig,
826
  status,
827
  gr.update(choices=choices, value=selected or "(clear)", visible=True),
828
+ labels,
829
  )
830
 
831
 
 
860
  color: #ffffff !important;
861
  }
862
 
863
+ /* Visibility checkboxes β€” compact */
864
+ .vis-cbg label {
865
+ color: #63348d !important;
866
+ border: 1px solid #63348d !important;
867
+ border-radius: 6px !important;
868
+ }
869
+ .vis-cbg label.selected {
870
+ background: #63348d !important;
871
+ color: #ffffff !important;
872
+ }
873
+ .vis-cbg label.selected * {
874
+ color: #ffffff !important;
875
+ }
876
+
877
  /* Input fields β€” white for contrast */
878
  textarea, input[type="text"] {
879
  background: #ffffff !important;
880
  }
881
  """
882
 
883
+ FORCE_LIGHT = """
884
+ <script>
885
+ if(!location.search.includes("__theme=light")){
886
+ const u=new URL(location);u.searchParams.set("__theme","light");location.replace(u);
887
+ }
888
+ </script>
889
+ <script>
890
+ // Camera tracker β€” polls Plotly camera into hidden textbox for Gradio to read
891
+ (function() {
892
+ console.log('[cam] Camera tracker script loaded');
893
+ var attempts = 0;
894
+ var interval = setInterval(function() {
895
+ attempts++;
896
+ var plots = document.querySelectorAll('.js-plotly-plot');
897
+ if (plots.length === 0) {
898
+ if (attempts % 20 === 0) console.log('[cam] waiting for plot...', attempts);
899
+ return;
900
+ }
901
+ var plot = plots[0];
902
+ if (!plot._fullLayout || !plot._fullLayout.scene || !plot._fullLayout.scene._scene) {
903
+ if (attempts % 20 === 0) console.log('[cam] plot found but no scene yet');
904
+ return;
905
+ }
906
+ try {
907
+ var cam = plot._fullLayout.scene._scene.getCamera();
908
+ var el = document.querySelector('#camera_txt textarea, #camera_txt input');
909
+ if (!el) {
910
+ console.log('[cam] cannot find #camera_txt element');
911
+ return;
912
+ }
913
+ var val = JSON.stringify(cam);
914
+ if (el.value !== val) {
915
+ el.value = val;
916
+ var nativeInputValueSetter = Object.getOwnPropertyDescriptor(
917
+ window.HTMLTextAreaElement.prototype, 'value'
918
+ ) || Object.getOwnPropertyDescriptor(
919
+ window.HTMLInputElement.prototype, 'value'
920
+ );
921
+ if (nativeInputValueSetter && nativeInputValueSetter.set) {
922
+ nativeInputValueSetter.set.call(el, val);
923
+ }
924
+ el.dispatchEvent(new Event('input', {bubbles: true}));
925
+ el.dispatchEvent(new Event('change', {bubbles: true}));
926
+ }
927
+ } catch(e) {
928
+ console.log('[cam] error:', e);
929
+ }
930
+ }, 500);
931
+ })();
932
+ </script>
933
+ """
934
 
935
  _LIGHT = {
936
  "button_primary_background_fill": "#63348d",
 
964
  font=gr.themes.GoogleFont("Inter"),
965
  ).set(**_ALL)
966
 
967
+ with gr.Blocks(title="Embedding Explorer") as demo:
968
 
969
+ # ── State ──
970
+ all_labels_state = gr.State([])
971
+ loading_share = gr.State(False) # suppress cascading events during share load
972
+ camera_txt = gr.Textbox(visible=False, elem_id="camera_txt")
973
+ share_params = gr.State({})
974
+
975
+ # Force light mode fallback (head param covers most cases, this catches HF Spaces)
976
  gr.HTML('<script>if(!location.search.includes("__theme=light"))'
977
  '{const u=new URL(location);u.searchParams.set("__theme","light");'
978
  'location.replace(u)}</script>')
 
1000
  lines=1,
1001
  )
1002
  with gr.Column(scale=1):
1003
+ with gr.Row():
1004
+ exp_btn = gr.Button("Explore", variant="primary")
1005
+ share_btn = gr.Button("Share", variant="secondary",
1006
+ scale=0, min_width=80)
1007
+ share_url = gr.Textbox(label="Share URL", visible=False,
1008
+ interactive=False, buttons=["copy"])
1009
  with gr.Column(elem_classes=["purple-examples"]):
1010
  gr.Examples(
1011
  examples=[[e] for e in EXAMPLES],
 
1014
  )
1015
  exp_plot = gr.Plot(label="Embedding Space")
1016
  exp_status = gr.Markdown("")
1017
+ vis_cbg = gr.CheckboxGroup(
1018
+ label="Visible items (uncheck to hide)",
1019
+ choices=[], value=[],
1020
+ visible=False, interactive=True,
1021
+ elem_classes=["vis-cbg"],
1022
+ )
1023
  exp_radio = gr.Radio(
1024
  label="Click to see nearest neighbors",
1025
  choices=[], value=None,
 
1027
  elem_classes=["nbr-radio"],
1028
  )
1029
 
1030
+ # ── Event handlers ──
1031
+
1032
+ def _parse_camera_json(camera_json):
1033
+ """Parse camera JSON string (from JS bridge) into Plotly camera dict."""
1034
+ if not camera_json:
1035
+ return None
1036
+ try:
1037
+ return json.loads(camera_json)
1038
+ except (json.JSONDecodeError, TypeError):
1039
+ return None
1040
+
1041
+ def on_explore(input_text):
1042
+ """Fresh explore β€” compute MDS, show all items, reset checkboxes."""
1043
+ fig, status, radio, labels = explore(input_text, None)
1044
+ cbg = gr.update(choices=labels, value=labels, visible=bool(labels))
1045
+ return fig, status, radio, labels, cbg
1046
+
1047
+ def on_radio(input_text, selected, all_labels, visible, camera_json, is_loading):
1048
+ """Neighbor selection β€” re-render with current visibility + camera."""
1049
+ if is_loading:
1050
+ return gr.update(), gr.update(), gr.update(), False
1051
+ hidden = set(all_labels) - set(visible) if all_labels and visible else set()
1052
+ camera = _parse_camera_json(camera_json)
1053
+ fig, status, radio, _ = explore(input_text, selected, hidden=hidden or None, camera=camera)
1054
+ return fig, status, radio, False
1055
+
1056
+ def on_visibility(input_text, selected, all_labels, visible, camera_json, is_loading):
1057
+ """Visibility toggle β€” re-render with updated hidden set + camera."""
1058
+ if is_loading:
1059
+ return gr.update(), gr.update(), gr.update(), False
1060
+ hidden = set(all_labels) - set(visible) if all_labels else set()
1061
+ # If selected item is now hidden, clear selection
1062
+ if selected and selected != "(clear)" and selected in hidden:
1063
+ selected = None
1064
+ camera = _parse_camera_json(camera_json)
1065
+ fig, status, radio, _ = explore(input_text, selected, hidden=hidden or None, camera=camera)
1066
+ return fig, status, radio, False
1067
+
1068
+ def on_share(input_text, selected, visible, camera_json, request: gr.Request):
1069
+ """Build share URL encoding current state."""
1070
+ params = {}
1071
+ if input_text and input_text.strip():
1072
+ params["q"] = input_text.strip()
1073
+ if selected and selected != "(clear)":
1074
+ params["sel"] = selected
1075
+ # Only encode visibility if some items are hidden
1076
+ if visible is not None and isinstance(visible, list):
1077
+ params["vis"] = ",".join(visible)
1078
+ if camera_json:
1079
+ encoded = _encode_camera(camera_json)
1080
+ if encoded:
1081
+ params["cam"] = encoded
1082
+ if not params.get("q"):
1083
+ return gr.update(value="Nothing to share", visible=True)
1084
+ # Build base URL from request (gets correct port for local dev)
1085
+ base_url = _BASE_URL
1086
+ if request:
1087
+ host = request.headers.get("host", "")
1088
+ if host:
1089
+ scheme = "https" if _SPACE_ID else "http"
1090
+ base_url = f"{scheme}://{host}/"
1091
+ long_url = base_url + "?" + urllib.parse.urlencode(params)
1092
+ # On localhost, just return the full URL (Rebrandly rejects non-public URLs)
1093
+ if "localhost" in long_url or "127.0.0.1" in long_url:
1094
+ return gr.update(value=long_url, visible=True)
1095
+ short = _shorten_url(long_url)
1096
+ return gr.update(value=short, visible=True)
1097
+
1098
+ # ── Wire up events ──
1099
+
1100
  exp_btn.click(
1101
+ on_explore,
1102
  inputs=[exp_in],
1103
+ outputs=[exp_plot, exp_status, exp_radio, all_labels_state, vis_cbg],
1104
  )
1105
  exp_in.submit(
1106
+ on_explore,
1107
  inputs=[exp_in],
1108
+ outputs=[exp_plot, exp_status, exp_radio, all_labels_state, vis_cbg],
1109
  )
1110
+ # Radio + visibility: camera_txt is kept up-to-date by polling script
1111
  exp_radio.change(
1112
+ on_radio,
1113
+ inputs=[exp_in, exp_radio, all_labels_state, vis_cbg, camera_txt, loading_share],
1114
+ outputs=[exp_plot, exp_status, exp_radio, loading_share],
1115
+ )
1116
+ vis_cbg.change(
1117
+ on_visibility,
1118
+ inputs=[exp_in, exp_radio, all_labels_state, vis_cbg, camera_txt, loading_share],
1119
+ outputs=[exp_plot, exp_status, exp_radio, loading_share],
1120
+ )
1121
+
1122
+ # Share: camera_txt kept up-to-date by polling script
1123
+ share_btn.click(
1124
+ fn=on_share,
1125
+ inputs=[exp_in, exp_radio, vis_cbg, camera_txt],
1126
+ outputs=[share_url],
1127
+ )
1128
+
1129
+ # ── Share URL loading ──
1130
+
1131
+ def load_share_params(request: gr.Request):
1132
+ """Step 1: Parse query params from URL."""
1133
+ qp = dict(request.query_params) if request else {}
1134
+ return qp
1135
+
1136
+ def apply_share_params(params):
1137
+ """Step 2: Apply share params β€” set input, run explore, apply visibility + camera."""
1138
+ if not params or "q" not in params:
1139
+ return (
1140
+ gr.update(), # exp_in
1141
+ gr.update(), # exp_plot
1142
+ gr.update(), # exp_status
1143
+ gr.update(), # exp_radio
1144
+ gr.update(), # vis_cbg
1145
+ [], # all_labels_state
1146
+ gr.update(), # camera_txt
1147
+ False, # loading_share
1148
+ )
1149
+
1150
+ input_text = params.get("q", "")
1151
+ selected = params.get("sel")
1152
+ if selected == "":
1153
+ selected = None
1154
+ vis_str = params.get("vis")
1155
+ cam_str = params.get("cam")
1156
+
1157
+ camera = _parse_camera(cam_str)
1158
+
1159
+ # First explore with all items visible to get labels
1160
+ _, _, _, labels = explore(input_text, None, camera=camera)
1161
+
1162
+ # Apply visibility
1163
+ if vis_str:
1164
+ visible = [v.strip() for v in vis_str.split(",")]
1165
+ hidden = set(labels) - set(visible)
1166
+ else:
1167
+ visible = labels
1168
+ hidden = set()
1169
+
1170
+ fig, status, radio, _ = explore(
1171
+ input_text, selected, hidden=hidden or None, camera=camera
1172
+ )
1173
+
1174
+ cbg = gr.update(
1175
+ choices=labels,
1176
+ value=visible,
1177
+ visible=bool(labels),
1178
+ )
1179
+
1180
+ # Pre-populate camera_txt so subsequent re-renders preserve camera
1181
+ camera_json = json.dumps(camera) if camera else ""
1182
+
1183
+ return (
1184
+ gr.update(value=input_text),
1185
+ fig,
1186
+ status,
1187
+ radio,
1188
+ cbg,
1189
+ labels,
1190
+ gr.update(value=camera_json),
1191
+ True, # loading_share β€” suppress cascading events
1192
+ )
1193
+
1194
+ demo.load(
1195
+ fn=load_share_params,
1196
+ outputs=[share_params],
1197
+ ).then(
1198
+ fn=apply_share_params,
1199
+ inputs=[share_params],
1200
+ outputs=[exp_in, exp_plot, exp_status, exp_radio, vis_cbg, all_labels_state, camera_txt, loading_share],
1201
  )
1202
 
1203
+ demo.launch(theme=THEME, css=CSS, head=FORCE_LIGHT)