Spaces:
Running
Running
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>
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.
|
| 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=
|
| 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 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 709 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 720 |
-
|
|
|
|
| 721 |
if nbr_data:
|
| 722 |
status += f" Β· {len(nbr_data)} neighbors of **{selected}**"
|
| 723 |
|
| 724 |
-
choices = ["(clear)"] +
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 804 |
|
| 805 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
exp_btn.click(
|
| 851 |
-
|
| 852 |
inputs=[exp_in],
|
| 853 |
-
outputs=[exp_plot, exp_status, exp_radio],
|
| 854 |
)
|
| 855 |
exp_in.submit(
|
| 856 |
-
|
| 857 |
inputs=[exp_in],
|
| 858 |
-
outputs=[exp_plot, exp_status, exp_radio],
|
| 859 |
)
|
|
|
|
| 860 |
exp_radio.change(
|
| 861 |
-
|
| 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)
|