Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <meta name="embrace-data" content="https://huggingface.co/datasets/mxlin043/EmbRACE/resolve/main/"> | |
| <title>EmbRACE Viewer</title> | |
| <!-- | |
| Read-only viewer for the released EmbRACE data, the project page's EmbRACE Viewer | |
| extended from its examples to every task. It reads the dataset repo's own files over | |
| HTTP, so the same page runs beside a local export (tools/release/export_hf_dataset.py) | |
| and as a static Space in front of the Hugging Face dataset repo. | |
| index/web/tasks.json every task, loaded once | |
| <split>/trajectory/<Env>/task_<Task>/infos.json the record of a task, loaded when it is opened | |
| <split>/trajectory/<Env>/task_<Task>/NNN.jpg its frames (split: dataset or benchmark) | |
| benchmark/success_region/<Env>/task_<Task>/... the two region images of a benchmark task | |
| The data location comes from ?data=<url>, else from the embrace-data meta tag above. | |
| ?split=dataset|benchmark &env=<Env> &type=0-5 &q=<text> &task=<Env>/<Task> &step=<n> | |
| restore a view, and the address bar follows the view, so a copied link reopens it. | |
| ?theme=dark switches to the dark palette; light, matching the page, is the default. | |
| --> | |
| <style> | |
| :root, [data-theme="light"] { | |
| --bg:#f5f5f7; --panel:#ffffff; --panel2:#fafafa; --head:#ffffff; --line:#e6e6e6; --line2:#eeeeee; | |
| --text:#222222; --text2:#333333; --muted:#6b6b6b; --faint:#a8a8a8; | |
| --accent:#FF3B30; --accent-soft:rgba(255,59,48,.07); --accent-line:rgba(255,59,48,.45); | |
| --hover:#f7f7f8; --img-bg:#f4f4f4; --card:#fcfcfc; --card-line:#ececec; | |
| --t0:#FF3B30; --t1:#FF9F0A; --t2:#e0b000; --t3:#32D74B; --t4:#40C8E0; --t5:#5E5CE6; | |
| --move:#388E3C; --turn:#7C4DFF; --look:#c05ad8; --interact:#E64A19; --finish:#e0a800; --midway:#d49b00; --idle:#999999; | |
| } | |
| [data-theme="dark"] { | |
| --bg:#0b0d12; --panel:#12151c; --panel2:#171b24; --head:#0e1117; --line:#252a35; --line2:#20242e; | |
| --text:#eceef2; --text2:#d6d9df; --muted:#8a90a0; --faint:#5a6070; | |
| --accent:#FF453A; --accent-soft:rgba(255,69,58,.12); --accent-line:rgba(255,69,58,.5); | |
| --hover:#1a1f29; --img-bg:#07090d; --card:#161a22; --card-line:#262b36; | |
| --t0:#FF453A; --t1:#FF9F0A; --t2:#FFD60A; --t3:#32D74B; --t4:#40C8E0; --t5:#7d7bff; | |
| --move:#4caf50; --turn:#8f6bff; --look:#ea80fc; --interact:#ff6a3d; --finish:#fbc02d; --midway:#ffe57f; --idle:#969696; | |
| } | |
| * { box-sizing:border-box; } | |
| html,body { margin:0; height:100%; overflow:hidden; background:var(--bg); color:var(--text); | |
| font-family:"Google Sans","Noto Sans","Segoe UI",Helvetica,Arial,sans-serif; } | |
| button,input,select { font:inherit; } | |
| .app { height:100vh; display:grid; grid-template-rows:58px 1fr; } | |
| header { display:flex; align-items:center; gap:14px; padding:0 20px; border-bottom:1px solid var(--line); background:var(--head); } | |
| .brand { font-weight:700; font-size:20px; letter-spacing:.2px; color:var(--text); display:flex; align-items:baseline; gap:6px; } | |
| /* Letter colours match the page title. */ | |
| .wordmark span { font-weight:800; } | |
| .header-stats { margin-left:auto; display:flex; gap:8px; } | |
| .pill { padding:5px 11px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:12px; background:var(--panel); } | |
| .workspace { min-height:0; display:grid; grid-template-columns:430px 340px minmax(520px,1fr); } | |
| .column { min-height:0; border-right:1px solid var(--line); background:var(--panel); display:flex; flex-direction:column; } | |
| .column:last-child { border-right:0; background:var(--bg); } | |
| .column-head { padding:12px 14px; border-bottom:1px solid var(--line); } | |
| /* Scrolling panes keep the scrollbar's width reserved whether or not they overflow, so content | |
| stays put when a longer task or a success-region card brings the scrollbar in. */ | |
| .scroll { min-height:0; overflow:auto; scrollbar-gutter:stable; } | |
| .controls { display:grid; gap:9px; } | |
| /* Dataset / Benchmark: the one choice everything else depends on, so it gets the full width. */ | |
| .set-switch { display:grid; grid-template-columns:1fr 1fr; border:1px solid var(--line); border-radius:9px; overflow:hidden; background:var(--panel2); } | |
| .set-btn { border:0; background:transparent; color:var(--muted); padding:8px 0; cursor:pointer; font-size:13px; font-weight:700; } | |
| .set-btn + .set-btn { border-left:1px solid var(--line); } | |
| .set-btn:hover { color:var(--text); } | |
| .set-btn .cnt { font-weight:600; opacity:.75; margin-left:4px; } | |
| .set-btn.active { background:var(--accent); color:#fff; } | |
| .search-row { display:grid; grid-template-columns:1fr 168px; gap:6px; } | |
| #search, #envSelect { width:100%; padding:7px 10px; border:1px solid var(--line); border-radius:7px; background:var(--panel2); color:var(--text); font-size:12px; outline:none; } | |
| #envSelect { padding:6px 8px; cursor:pointer; } | |
| #search:focus, #envSelect:focus { border-color:var(--accent-line); } | |
| #search::placeholder { color:var(--faint); } | |
| .filter-row { display:flex; align-items:center; gap:4px; flex-wrap:wrap; } | |
| .filter { border:1px solid var(--line); background:var(--panel2); color:var(--muted); border-radius:5px; padding:2px 9px; cursor:pointer; font-size:11px; transition:all .15s; } | |
| .filter:hover { border-color:var(--accent-line); color:var(--text); } | |
| .filter.active { color:var(--accent); border-color:var(--accent-line); background:var(--accent-soft); font-weight:700; } | |
| .task-table-head,.task { display:grid; grid-template-columns:36px minmax(0,1fr) 28px 118px; align-items:center; gap:6px; } | |
| .task-table-head { padding:6px 12px; background:var(--panel2); border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; } | |
| /* The list holds every task of a split, so only the rows in view are drawn. */ | |
| .rows { position:relative; } | |
| .task { position:absolute; left:0; right:0; height:32px; text-align:left; border:0; border-bottom:1px solid var(--line2); background:transparent; color:var(--text); padding:0 12px; cursor:pointer; font-size:12px; } | |
| .task:hover { background:var(--hover); } | |
| .task.active { background:var(--accent-soft); box-shadow:inset 3px 0 0 var(--accent); } | |
| .task-index { color:var(--faint); font-size:11px; } | |
| .task-instr { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; } | |
| .task-map { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:11px; } | |
| .type-badge { display:inline-block; text-align:center; border-radius:4px; padding:1px 0; font-size:10px; font-weight:800; color:#fff; } | |
| .type-0 { background:var(--t0); } .type-1 { background:var(--t1); } .type-2 { background:var(--t2); } | |
| .type-3 { background:var(--t3); } .type-4 { background:var(--t4); } .type-5 { background:var(--t5); } | |
| .foot { flex:0 0 auto; padding:8px 12px; border-top:1px solid var(--line); background:var(--panel2); color:var(--muted); font-size:11px; } | |
| .summary { display:grid; gap:10px; } | |
| .task-id-card { border:1px solid var(--card-line); border-radius:9px; padding:8px 10px; background:var(--card); } | |
| .task-id-head { display:flex; align-items:center; justify-content:space-between; } | |
| .label { color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:.6px; font-weight:700; } | |
| .copy-btn { border:1px solid var(--line); background:var(--panel); color:var(--muted); border-radius:5px; padding:1px 8px; font-size:11px; cursor:pointer; } | |
| .copy-btn:hover { color:var(--accent); border-color:var(--accent-line); } | |
| .task-id-value { display:block; margin-top:4px; font-size:12px; word-break:break-all; color:var(--text); } | |
| .facts { display:grid; grid-template-columns:auto 1fr; gap:4px 12px; font-size:12.5px; } | |
| .facts .k { color:var(--muted); } | |
| .facts code { color:var(--faint); font-size:11px; margin-left:4px; } | |
| /* The instruction reads like the page's Data Example: a small red label and the sentence. */ | |
| .instruction { font-size:14.5px; line-height:1.45; font-weight:600; color:var(--text); padding:10px 12px; | |
| border:1px solid var(--card-line); border-radius:9px; background:var(--card); } | |
| .instruction .lab { display:block; color:var(--accent); font-size:10.5px; font-weight:800; text-transform:uppercase; letter-spacing:.6px; margin-bottom:3px; } | |
| .action { width:100%; display:grid; grid-template-columns:34px 1fr; align-items:center; gap:8px; text-align:left; border:0; border-bottom:1px solid var(--line2); background:transparent; color:var(--text); padding:8px 14px; cursor:pointer; } | |
| .action:hover { background:var(--hover); } | |
| .action.active { background:var(--accent-soft); box-shadow:inset 3px 0 0 var(--accent); } | |
| .action-index { color:var(--faint); font-size:11px; font-family:ui-monospace,monospace; } | |
| .action-name { font-weight:700; font-size:13px; } | |
| .detail { height:100%; overflow:auto; scrollbar-gutter:stable; padding:16px 20px 24px; } | |
| .detail-head { display:flex; align-items:center; gap:12px; margin-bottom:14px; flex-wrap:wrap; } | |
| .step { display:inline-flex; align-items:baseline; gap:4px; } | |
| .step-now { font-size:22px; font-weight:800; color:var(--accent); line-height:1; } | |
| .step-sep { font-size:15px; color:var(--faint); } | |
| .step-all { font-size:15px; font-weight:700; color:var(--muted); } | |
| .step-word { font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--faint); margin-left:3px; } | |
| /* The same pill the page draws for the current action. */ | |
| .act-pill { display:inline-block; padding:4px 13px; border-radius:999px; color:#fff; font-size:13px; font-weight:800; letter-spacing:.02em; white-space:nowrap; } | |
| .ref-tag { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; } | |
| .step-nav { margin-left:auto; display:inline-flex; border-radius:9px; overflow:hidden; box-shadow:0 1px 2px rgba(0,0,0,.08); } | |
| .nav-btn { border:0; background:var(--accent); color:#fff; padding:6px 14px; cursor:pointer; font-size:12.5px; font-weight:700; } | |
| .nav-btn + .nav-btn { border-left:1px solid rgba(255,255,255,.35); } | |
| .nav-btn:hover:not(:disabled) { filter:brightness(.92); } | |
| .nav-btn:disabled { background:#dcdcdc; color:#fff; cursor:default; } | |
| [data-theme="dark"] .nav-btn:disabled { background:#343a46; color:#8a90a0; } | |
| .images { display:grid; grid-template-columns:1fr 1fr; gap:14px; } | |
| .image-card { position:relative; border-radius:10px; overflow:hidden; background:var(--img-bg); aspect-ratio:4/3; } | |
| .image-card img { width:100%; height:100%; object-fit:contain; display:block; } | |
| .image-label { position:absolute; top:8px; left:8px; background:rgba(0,0,0,.6); color:#fff; font-size:11px; font-weight:700; padding:3px 8px; border-radius:5px; } | |
| /* The rationale card follows the page's Data Example side panel: a light card, dark text, | |
| and the label in the page's accent, rather than a black block of its own. */ | |
| .rationale { margin-top:16px; border:1px solid var(--card-line); border-radius:10px; background:var(--card); padding:14px 18px; } | |
| .rationale .rl { display:block; color:var(--accent); font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.6px; margin-bottom:6px; } | |
| .rationale .rt { font-size:15.5px; line-height:1.62; color:var(--text2); } | |
| /* Success region: the two canonical images a benchmark task is judged against, in the | |
| place the rationale card takes in the dataset view. */ | |
| .region { margin-top:16px; border:1px solid var(--card-line); border-radius:10px; background:var(--card); padding:14px 18px 16px; } | |
| .region .rl { display:block; color:var(--accent); font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.6px; margin-bottom:2px; } | |
| .region .rn { color:var(--muted); font-size:12.5px; line-height:1.5; margin-bottom:10px; } | |
| .region-stage { margin-top:10px; } | |
| .region-stage + .region-stage { margin-top:14px; } | |
| .stage-tag { display:inline-block; font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.5px; color:var(--text2); margin-bottom:6px; } | |
| .region-pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; } | |
| .region-img { position:relative; border-radius:9px; overflow:hidden; aspect-ratio:4/3; background:#ffffff; border:1px solid var(--card-line); cursor:zoom-in; padding:0; width:100%; } | |
| .region-img img { width:100%; height:100%; object-fit:contain; display:block; } | |
| .region-fig { margin:0; } | |
| .region-fig figcaption { margin-top:5px; color:var(--muted); font-size:11.5px; font-weight:600; text-align:center; } | |
| .zoom { position:fixed; inset:0; background:rgba(10,12,16,.82); display:none; align-items:center; justify-content:center; z-index:50; cursor:zoom-out; padding:24px; } | |
| .zoom.open { display:flex; } | |
| .zoom img { max-width:100%; max-height:100%; border-radius:8px; background:#fff; box-shadow:0 10px 40px rgba(0,0,0,.4); } | |
| .empty { padding:24px; color:var(--muted); font-size:13px; text-align:center; } | |
| .keys { color:var(--faint); font-size:11px; margin-top:12px; } | |
| @media (max-width:1200px) { | |
| html,body { overflow:auto; height:auto; } | |
| .app { height:auto; } | |
| .workspace { grid-template-columns:1fr; } | |
| .column { border-right:0; border-bottom:1px solid var(--line); } | |
| .scroll { max-height:340px; } | |
| #taskList { height:340px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header> | |
| <div class="brand"> | |
| <span class="wordmark"><span style="color:#FF3B30">E</span><span style="color:#FF9F0A">m</span><span style="color:#FFD60A">b</span><span style="color:#32D74B">R</span><span style="color:#40C8E0">A</span><span style="color:#5E5CE6">C</span><span style="color:#FF2D9B">E</span></span> | |
| <span>Viewer</span> | |
| </div> | |
| <div class="header-stats"><span class="pill" id="taskCount">Loading…</span></div> | |
| </header> | |
| <main class="workspace"> | |
| <section class="column"> | |
| <div class="column-head"> | |
| <div class="controls"> | |
| <div class="set-switch" role="tablist"> | |
| <button class="set-btn" data-split="dataset" type="button" role="tab">Dataset</button> | |
| <button class="set-btn" data-split="benchmark" type="button" role="tab">Benchmark</button> | |
| </div> | |
| <div class="search-row"> | |
| <input id="search" placeholder="Search instruction, task or environment…" aria-label="Search tasks"> | |
| <select id="envSelect" aria-label="Environment"></select> | |
| </div> | |
| <div class="filter-row" id="typeFilter"></div> | |
| </div> | |
| </div> | |
| <div class="task-table-head"><span>#</span><span>Instruction</span><span>Type</span><span>Environment</span></div> | |
| <div class="scroll" id="taskList" style="flex:1"><div class="rows" id="rows"><div class="empty">Loading…</div></div></div> | |
| <div class="foot" id="foot"></div> | |
| </section> | |
| <section class="column"> | |
| <div class="column-head"><div id="taskSummary"><div class="empty">Select a task.</div></div></div> | |
| <div class="scroll" id="actionList" style="flex:1"></div> | |
| </section> | |
| <section class="column"> | |
| <div class="detail" id="detail"><div class="empty">Select a task to walk through its steps.</div></div> | |
| </section> | |
| </main> | |
| </div> | |
| <div class="zoom" id="zoom" role="dialog" aria-label="Enlarged image"><img id="zoomImg" alt=""></div> | |
| <script> | |
| (function () { | |
| "use strict"; | |
| const params = new URLSearchParams(window.location.search); | |
| document.documentElement.dataset.theme = params.get("theme") === "dark" ? "dark" : "light"; | |
| let DATA = params.get("data") || document.querySelector('meta[name="embrace-data"]').content; | |
| if (!DATA.endsWith("/")) DATA += "/"; | |
| const TYPE_NAMES = ["Basic", "Exploration", "Dynamic Spatial-Semantic", "Multi-stage", | |
| "Open Door", "Pick & Drop"]; | |
| const SPLITS = { | |
| dataset: "Training demonstrations, each step with the rationale for its action.", | |
| benchmark: "Held-out test tasks, each with its reference human demonstration and the success region it is judged against." | |
| }; | |
| // The action families of the qualitative videos, so the two agree. | |
| const ACTION_FAMILY = { | |
| MoveForward: "move", MoveBackward: "move", | |
| TurnLeft: "turn", TurnRight: "turn", | |
| LookUp: "look", LookDown: "look", | |
| OpenDoor: "interact", Pick: "interact", Drop: "interact", | |
| Finish: "finish", MidwayTarget: "midway", Idle: "idle" | |
| }; | |
| const actionColor = name => `var(--${ACTION_FAMILY[name] || "idle"})`; | |
| const esc = value => { const d = document.createElement("div"); d.textContent = value ?? ""; return d.innerHTML; }; | |
| const fmt = n => n.toLocaleString("en-US"); | |
| const frame = i => String(i).padStart(3, "0") + ".jpg"; | |
| const $ = id => document.getElementById(id); | |
| const ROW_H = 32; | |
| const state = { envs: [], pd: {}, all: [], split: "dataset", env: "all", type: "all", | |
| filtered: [], task: null, record: null, step: 0 }; | |
| const records = new Map(); | |
| // On the Hub every task folder carries the prefix task_ (the Hub refuses paths with "/x00"). | |
| const trajUrl = (t, file) => `${DATA}${t.split}/trajectory/${t.env.id}/task_${t.stem}/${file}`; | |
| const areaUrl = (t, file) => `${DATA}${t.split}/success_region/${t.env.id}/task_${t.stem}/${file}`; | |
| async function loadIndex() { | |
| const url = DATA + "index/web/tasks.json"; | |
| const res = await fetch(url); | |
| if (!res.ok) throw new Error(`Could not read ${url} (${res.status})`); | |
| const idx = await res.json(); | |
| const c = Object.fromEntries(idx.columns.map((name, i) => [name, i])); | |
| state.envs = idx.environments; | |
| state.pd = idx.pd_objects; | |
| state.all = idx.tasks.map(r => { | |
| const env = state.envs[r[c.env]]; | |
| return { | |
| env, split: env.split, stem: r[c.task], type: r[c.type], steps: r[c.steps], | |
| instruction: r[c.instruction], pd: r[c.pd_id], regions: r[c.regions], | |
| haystack: `${r[c.instruction]} ${r[c.task]} ${env.id} ${env.name}`.toLowerCase() | |
| }; | |
| }); | |
| } | |
| function inSplit() { return state.all.filter(t => t.split === state.split); } | |
| function switchSplit(split, keep) { | |
| state.split = split; | |
| const tasks = inSplit(); | |
| const env = keep && keep.env, type = keep && keep.type; | |
| state.env = env && tasks.some(t => t.env.id === env) ? env : "all"; | |
| state.type = type !== undefined && tasks.some(t => t.type === type) ? type : "all"; | |
| document.querySelectorAll(".set-btn").forEach(b => | |
| b.classList.toggle("active", b.dataset.split === split)); | |
| const envCount = new Set(tasks.map(t => t.env.id)).size; | |
| const steps = tasks.reduce((s, t) => s + t.steps, 0); | |
| $("foot").textContent = `${fmt(tasks.length)} tasks · ${envCount} environments · ${fmt(steps)} steps`; | |
| applyFilters(); | |
| const wanted = keep && keep.task; | |
| const first = (wanted && tasks.find(t => `${t.env.id}/${t.stem}` === wanted || t.stem === wanted)) | |
| || state.filtered[0]; | |
| if (first) selectTask(first, keep && keep.step ? keep.step : 0); | |
| } | |
| const searchWords = () => { | |
| const q = $("search").value.trim().toLowerCase(); | |
| return q ? q.split(/\s+/) : []; | |
| }; | |
| const hit = (t, words) => words.every(w => t.haystack.includes(w)); | |
| // Every number beside a filter option counts tasks: the tasks the list would show with that | |
| // option chosen and the other filters kept, so a number always matches what the list shows. | |
| // Options that would show nothing are left out, except the one already chosen, which stays | |
| // with its 0 so an empty list explains itself. | |
| function refreshFacets(words) { | |
| const tasks = inSplit(), envCounts = {}, typeCounts = {}; | |
| let envAll = 0, typeAll = 0; | |
| tasks.forEach(t => { | |
| if (!hit(t, words)) return; | |
| if (state.type === "all" || t.type === state.type) { | |
| envCounts[t.env.id] = (envCounts[t.env.id] || 0) + 1; envAll++; | |
| } | |
| if (state.env === "all" || t.env.id === state.env) { | |
| typeCounts[t.type] = (typeCounts[t.type] || 0) + 1; typeAll++; | |
| } | |
| }); | |
| const envs = [...new Set(tasks.map(t => t.env))].sort((a, b) => a.name.localeCompare(b.name)); | |
| const select = $("envSelect"); | |
| select.innerHTML = `<option value="all">All environments (${fmt(envAll)})</option>` + | |
| envs.map(e => { | |
| const n = envCounts[e.id] || 0; | |
| return n || e.id === state.env | |
| ? `<option value="${esc(e.id)}">${esc(e.name)} (${fmt(n)})</option>` : ""; | |
| }).join(""); | |
| select.value = state.env; | |
| $("typeFilter").innerHTML = | |
| `<button class="filter" data-value="all" type="button" title="${fmt(typeAll)} tasks">All</button>` + | |
| TYPE_NAMES.map((name, i) => { | |
| const n = typeCounts[i] || 0; | |
| return n || state.type === i | |
| ? `<button class="filter" data-value="${i}" type="button" title="${esc(name)} · ${fmt(n)} tasks">T${i}</button>` : ""; | |
| }).join(""); | |
| $("typeFilter").querySelectorAll(".filter").forEach(b => | |
| b.classList.toggle("active", b.dataset.value === String(state.type))); | |
| } | |
| function applyFilters() { | |
| const words = searchWords(); | |
| refreshFacets(words); | |
| const tasks = inSplit(); | |
| state.filtered = tasks.filter(t => | |
| (state.env === "all" || t.env.id === state.env) && | |
| (state.type === "all" || t.type === state.type) && hit(t, words)); | |
| $("taskCount").textContent = `${fmt(state.filtered.length)}/${fmt(tasks.length)} tasks`; | |
| $("taskList").scrollTop = 0; | |
| renderTasks(); | |
| syncUrl(); | |
| } | |
| function renderTasks() { | |
| const rows = $("rows"); | |
| if (!state.filtered.length) { | |
| rows.style.height = ""; | |
| rows.innerHTML = '<div class="empty">No matching tasks.</div>'; | |
| return; | |
| } | |
| rows.style.height = `${state.filtered.length * ROW_H}px`; | |
| drawRows(); | |
| } | |
| function drawRows() { | |
| if (!state.filtered.length) return; | |
| const list = $("taskList"); | |
| const start = Math.max(0, Math.floor(list.scrollTop / ROW_H) - 10); | |
| const end = Math.min(state.filtered.length, Math.ceil((list.scrollTop + list.clientHeight) / ROW_H) + 10); | |
| let html = ""; | |
| for (let i = start; i < end; i++) { | |
| const t = state.filtered[i]; | |
| html += `<button class="task${state.task === t ? " active" : ""}" data-i="${i}" type="button" style="top:${i * ROW_H}px" title="${esc(t.instruction)}"> | |
| <span class="task-index">${i + 1}</span> | |
| <span class="task-instr">${esc(t.instruction)}</span> | |
| <span class="type-badge type-${t.type}" title="${esc(TYPE_NAMES[t.type])}">T${t.type}</span> | |
| <span class="task-map" title="${esc(t.env.name)}">${esc(t.env.name)}</span> | |
| </button>`; | |
| } | |
| $("rows").innerHTML = html; | |
| } | |
| function revealTask() { | |
| const i = state.filtered.indexOf(state.task); | |
| if (i < 0) return; | |
| const list = $("taskList"); | |
| const top = i * ROW_H; | |
| if (top < list.scrollTop || top + ROW_H > list.scrollTop + list.clientHeight) | |
| list.scrollTop = Math.max(0, top - list.clientHeight / 2); | |
| drawRows(); | |
| } | |
| async function selectTask(task, step) { | |
| state.task = task; state.record = null; state.step = step || 0; | |
| drawRows(); renderSummary(); | |
| $("actionList").innerHTML = '<div class="empty">Loading…</div>'; | |
| $("detail").innerHTML = '<div class="empty">Loading…</div>'; | |
| const key = `${task.env.id}/${task.stem}`; | |
| let record = records.get(key); | |
| if (!record) { | |
| try { | |
| const res = await fetch(trajUrl(task, "infos.json")); | |
| if (!res.ok) throw new Error(`${res.status}`); | |
| record = await res.json(); | |
| records.set(key, record); | |
| } catch (err) { | |
| if (state.task === task) | |
| $("detail").innerHTML = `<div class="empty">Could not load this task (${esc(err.message)}).</div>`; | |
| return; | |
| } | |
| } | |
| if (state.task !== task) return; // another task was opened meanwhile | |
| state.record = record; | |
| state.step = Math.min(state.step, record.Action.length - 1); | |
| for (let i = 0; i <= record.Action.length; i++) new Image().src = trajUrl(task, frame(i)); | |
| renderActions(); renderDetail(); syncUrl(); | |
| } | |
| function renderSummary() { | |
| const t = state.task; | |
| const object = t.pd !== null && t.pd !== undefined | |
| ? `<span class="k">Object</span><span>${esc(state.pd[String(t.pd)] || t.pd)}</span>` : ""; | |
| $("taskSummary").innerHTML = ` | |
| <div class="summary"> | |
| <div class="task-id-card"> | |
| <div class="task-id-head"><span class="label">Task ID</span><button class="copy-btn" id="copyId" type="button">Copy</button></div> | |
| <code class="task-id-value">${esc(t.stem)}</code> | |
| </div> | |
| <div class="facts"> | |
| <span class="k">Type</span><span>T${t.type} · ${esc(TYPE_NAMES[t.type])}</span> | |
| <span class="k">Environment</span><span>${esc(t.env.name)}<code>${esc(t.env.id)}</code></span> | |
| <span class="k">Setting</span><span>${esc(t.env.setting)}</span> | |
| <span class="k">Steps</span><span>${t.steps}</span> | |
| ${object} | |
| </div> | |
| <div class="instruction"><span class="lab">Instruction</span>${esc(t.instruction)}</div> | |
| </div>`; | |
| $("copyId").addEventListener("click", async (e) => { | |
| const text = t.stem, btn = e.currentTarget; | |
| try { | |
| if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(text); | |
| else { | |
| const a = document.createElement("textarea"); | |
| a.value = text; a.style.position = "fixed"; a.style.opacity = "0"; | |
| document.body.appendChild(a); a.select(); document.execCommand("copy"); a.remove(); | |
| } | |
| btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1000); | |
| } catch (err) { btn.textContent = "Select ID"; } | |
| }); | |
| } | |
| function renderActions() { | |
| const list = $("actionList"); | |
| list.innerHTML = state.record.Action.map((a, i) => ` | |
| <button class="action${i === state.step ? " active" : ""}" data-i="${i}" type="button"> | |
| <span class="action-index">${String(i + 1).padStart(2, "0")}</span> | |
| <span class="action-name" style="color:${actionColor(a)}">${esc(a)}</span> | |
| </button>`).join(""); | |
| const active = list.querySelector(".action.active"); | |
| if (active) active.scrollIntoView({ block: "nearest" }); | |
| } | |
| function selectStep(i) { | |
| if (!state.record || i < 0 || i >= state.record.Action.length) return; | |
| state.step = i; renderActions(); renderDetail(); syncUrl(); | |
| } | |
| function renderDetail() { | |
| const t = state.task, r = state.record, i = state.step, n = r.Action.length; | |
| const action = r.Action[i]; | |
| const rationale = r.Rationale | |
| ? `<div class="rationale"><span class="rl">Rationale</span><div class="rt">${esc(r.Rationale[i])}</div></div>` | |
| : ""; | |
| const tag = t.split === "benchmark" ? `<span class="ref-tag">Reference demonstration</span>` : ""; | |
| const region = t.regions && t.regions.length ? regionHtml(t) : ""; | |
| $("detail").innerHTML = ` | |
| <div class="detail-head"> | |
| <span class="step"><span class="step-now">${i + 1}</span><span class="step-sep">/</span><span class="step-all">${n}</span><span class="step-word">steps</span></span> | |
| <span class="act-pill" style="background:${actionColor(action)}">${esc(action)}</span> | |
| ${tag} | |
| <div class="step-nav"> | |
| <button class="nav-btn" id="prev" type="button" ${i === 0 ? "disabled" : ""}>← Previous</button> | |
| <button class="nav-btn" id="next" type="button" ${i === n - 1 ? "disabled" : ""}>Next →</button> | |
| </div> | |
| </div> | |
| <div class="images"> | |
| <div class="image-card"><span class="image-label">Before Action</span><img src="${esc(trajUrl(t, frame(i)))}" alt="Ego view before the action"></div> | |
| <div class="image-card"><span class="image-label">After Action</span><img src="${esc(trajUrl(t, frame(i + 1)))}" alt="Ego view after the action"></div> | |
| </div> | |
| ${rationale} | |
| ${region} | |
| <div class="keys">← → or ↑ ↓ to move between steps · click a success-region image to enlarge it</div>`; | |
| $("prev").addEventListener("click", () => selectStep(i - 1)); | |
| $("next").addEventListener("click", () => selectStep(i + 1)); | |
| document.querySelectorAll(".region-img").forEach(b => b.addEventListener("click", () => { | |
| $("zoomImg").src = b.dataset.src; | |
| $("zoom").classList.add("open"); | |
| })); | |
| } | |
| function regionHtml(t) { | |
| const figure = (file, alt, caption) => ` | |
| <figure class="region-fig"> | |
| <button class="region-img" type="button" data-src="${esc(areaUrl(t, file))}"> | |
| <img src="${esc(areaUrl(t, file))}" alt="${alt}"> | |
| </button> | |
| <figcaption>${caption}</figcaption> | |
| </figure>`; | |
| const multi = t.regions.length > 1; | |
| const stages = t.regions.map(([stage, xy, ue]) => ` | |
| <div class="region-stage"> | |
| ${multi ? `<span class="stage-tag">${stage === "midway" ? "Midway target" : "Final target"}</span>` : ""} | |
| <div class="region-pair"> | |
| ${figure(xy, "Top-down plot of the success region", "Top-down plot")} | |
| ${figure(ue, "Success region drawn over the scene", "Scene overlay")} | |
| </div> | |
| </div>`).join(""); | |
| return `<div class="region"><span class="rl">Success region</span> | |
| <div class="rn">The orange box is the target's footprint, and the outlined area around it is the success region.</div> | |
| ${stages}</div>`; | |
| } | |
| // The address bar follows the view, so a copied link reopens it. | |
| function syncUrl() { | |
| const p = new URLSearchParams(); | |
| ["data", "theme"].forEach(k => { if (params.get(k)) p.set(k, params.get(k)); }); | |
| p.set("split", state.split); | |
| if (state.env !== "all") p.set("env", state.env); | |
| if (state.type !== "all") p.set("type", state.type); | |
| const q = $("search").value.trim(); | |
| if (q) p.set("q", q); | |
| if (state.task) p.set("task", `${state.task.env.id}/${state.task.stem}`); | |
| if (state.task && state.step > 0) p.set("step", state.step + 1); | |
| history.replaceState(null, "", `${location.pathname}?${p.toString()}`); | |
| } | |
| const zoom = $("zoom"); | |
| zoom.addEventListener("click", () => zoom.classList.remove("open")); | |
| document.addEventListener("keydown", e => { | |
| if (e.key === "Escape" && zoom.classList.contains("open")) { zoom.classList.remove("open"); return; } | |
| if (zoom.classList.contains("open")) return; | |
| if (!state.record || e.target.matches("input,textarea,select")) return; | |
| if (["ArrowDown", "ArrowRight", "j"].includes(e.key)) { e.preventDefault(); selectStep(state.step + 1); } | |
| if (["ArrowUp", "ArrowLeft", "k"].includes(e.key)) { e.preventDefault(); selectStep(state.step - 1); } | |
| }); | |
| $("rows").addEventListener("click", e => { | |
| const b = e.target.closest(".task"); | |
| if (b) selectTask(state.filtered[Number(b.dataset.i)], 0); | |
| }); | |
| $("actionList").addEventListener("click", e => { | |
| const b = e.target.closest(".action"); | |
| if (b) selectStep(Number(b.dataset.i)); | |
| }); | |
| $("taskList").addEventListener("scroll", () => requestAnimationFrame(drawRows)); | |
| window.addEventListener("resize", () => requestAnimationFrame(drawRows)); | |
| $("search").addEventListener("input", applyFilters); | |
| $("envSelect").addEventListener("change", e => { | |
| state.env = e.target.value; | |
| applyFilters(); | |
| }); | |
| $("typeFilter").addEventListener("click", e => { | |
| const b = e.target.closest(".filter"); | |
| if (!b) return; | |
| state.type = b.dataset.value === "all" ? "all" : Number(b.dataset.value); | |
| applyFilters(); | |
| }); | |
| loadIndex().then(() => { | |
| const counts = { dataset: 0, benchmark: 0 }; | |
| state.all.forEach(t => { counts[t.split]++; }); | |
| // What each split holds is told on hover, so switching never moves the controls below. | |
| document.querySelectorAll(".set-btn").forEach(b => { | |
| b.innerHTML += `<span class="cnt">${fmt(counts[b.dataset.split])}</span>`; | |
| b.title = SPLITS[b.dataset.split]; | |
| b.addEventListener("click", () => { if (b.dataset.split !== state.split) switchSplit(b.dataset.split); }); | |
| }); | |
| if (params.get("q")) $("search").value = params.get("q"); | |
| const wanted = params.get("task"); | |
| const fromTask = wanted && state.all.find(t => `${t.env.id}/${t.stem}` === wanted || t.stem === wanted); | |
| const split = fromTask ? fromTask.split : (params.get("split") === "benchmark" ? "benchmark" : "dataset"); | |
| const type = params.get("type"); | |
| switchSplit(split, { | |
| env: params.get("env"), | |
| type: type !== null && /^[0-5]$/.test(type) ? Number(type) : undefined, | |
| task: wanted, | |
| step: Math.max(0, parseInt(params.get("step") || "1", 10) - 1) | |
| }); | |
| revealTask(); | |
| }).catch(err => { | |
| $("rows").innerHTML = `<div class="empty">${esc(err.message)}</div>`; | |
| $("taskCount").textContent = "No data"; | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |