EgoNetDatasetResearch / src /preview.js
DarthSoluke's picture
Refactor the atlas into a dataset-first modular frontend
831819e verified
Raw History Blame Contribute Delete
9.32 kB
import { esc, icon, external, section } from "./ui.js";
export function previewHTML(dataset, context) {
const previews = context.previews[dataset.id] || [];
if (!previews.length) return "";
return `<section class="preview" id="dataset-preview" aria-label="${esc(dataset.name)}样例">
<div class="preview-toolbar">${icon("chevron-left", "上一个样例", "data-prev")}
<select data-sample aria-label="样例">${previews.map((p, i) => `<option value="${i}">${i + 1} · ${esc(p.caption)}</option>`).join("")}</select>
${icon("chevron-right", "下一个样例", "data-next")}
<select data-mode aria-label="标注类型"></select>
</div>
<div class="preview-pair">
<figure><figcaption data-rgb-label>原始图像</figcaption><svg data-left role="img" aria-label="原始图像" preserveAspectRatio="xMidYMid meet"><image data-rgb/></svg></figure>
<figure><figcaption><span data-gt-label>来源标注</span><span data-loading role="status" aria-live="polite">加载中</span></figcaption><svg data-right role="img" aria-label="来源标注叠加" preserveAspectRatio="xMidYMid meet"><image data-base/><image data-overlay/></svg></figure>
</div>
<div class="preview-adjustments">
<label>透明度<input data-alpha aria-label="叠加透明度" type="range" min="0" max="100" value="100"><output data-alpha-value>100%</output></label>
<label>缩放<input data-zoom aria-label="同步缩放" type="range" min="1" max="8" step="0.1" value="1"><output data-zoom-value>1×</output></label>
<div class="button-group">${icon("scan", "定位手部", "data-hand")}${icon("maximize", "显示全图", "data-reset")}</div>
</div>
<div class="preview-thumbs" aria-label="选择样例">${previews.map((p, i) => `<button type="button" data-preview="${i}" aria-label="样例${i + 1}"><img src="${esc(p.rgb)}" alt="" loading="lazy"><span>${i + 1}</span></button>`).join("")}</div>
<details class="provenance"><summary>来源与抽样</summary><div data-source class="source-content"></div>${samplingHTML(dataset.id, context.sampling)}</details>
</section>`;
}
export function samplingHTML(id, sampling) {
const sample = sampling.datasets?.[id];
if (!sample) return "";
return `<dl class="facts compact">${Object.entries(sample.categories)
.map(
([dimension, groups]) =>
`<div><dt>${esc(dimension)}</dt><dd>${Object.entries(groups)
.map(([name, n]) => `${esc(name)} ${n.selected}/${n.pool}`)
.join(" · ")}</dd></div>`,
)
.join(
"",
)}</dl><p class="source-note">${esc(sampling.strategy)} ${esc(sampling.dimensions_note)}</p>`;
}
export function bindPreview(dataset, context) {
const root = document.getElementById("dataset-preview");
if (!root) return;
const previews = context.previews[dataset.id];
const find = (name) => root.querySelector(`[data-${name}]`);
let index = 0,
box,
version = 0;
function viewport() {
const zoom = Number(find("zoom").value);
const w = box[2] / zoom,
h = box[3] / zoom;
const viewBox = [
box[0] + (box[2] - w) / 2,
box[1] + (box[3] - h) / 2,
w,
h,
].join(" ");
find("left").setAttribute("viewBox", viewBox);
find("right").setAttribute("viewBox", viewBox);
find("zoom-value").textContent = `${Number(zoom.toFixed(1))}×`;
}
async function annotation() {
const sample = previews[index],
mode = sample.modes[Number(find("mode").value)];
const own = ++version,
pane = find("right"),
loading = find("loading");
find("gt-label").textContent =
mode.label + (dataset.method === "pseudo" ? " · 预测标注" : "");
loading.hidden = false;
loading.textContent = "加载中";
pane.setAttribute("aria-busy", "true");
const images = [];
try {
await Promise.all(
[
["base", sample.rgb],
["overlay", mode.src],
].map(
([name, url]) =>
new Promise((resolve, reject) => {
const image = document.createElementNS(
"http://www.w3.org/2000/svg",
"image",
);
image.dataset[name] = "";
image.setAttribute("width", sample.width);
image.setAttribute("height", sample.height);
image.style.visibility = "hidden";
if (name === "overlay")
image.style.opacity = Number(find("alpha").value) / 100;
image.onload = resolve;
image.onerror = reject;
find(name).replaceWith(image);
images.push(image);
image.setAttribute("href", url);
}),
),
);
if (own !== version || !root.isConnected) return;
images.forEach((image) => {
image.style.visibility = "visible";
});
loading.hidden = true;
} catch (_) {
if (own !== version || !root.isConnected) return;
loading.textContent = "图像加载失败";
} finally {
if (own === version && root.isConnected)
pane.setAttribute("aria-busy", "false");
}
}
function show() {
const sample = previews[index];
find("sample").value = index;
for (const [name, value] of [
["width", sample.width],
["height", sample.height],
["href", sample.rgb],
])
find("rgb").setAttribute(name, value);
find("rgb-label").textContent = sample.rgb_label;
find("mode").innerHTML = sample.modes
.map((m, i) => `<option value="${i}">${esc(m.label)}</option>`)
.join("");
annotation();
box = [0, 0, sample.width, sample.height];
find("zoom").value = 1;
viewport();
find("hand").hidden = !sample.crop;
find("source").innerHTML =
`<p>${esc(sample.source)}</p>${sample.note ? `<p>${esc(sample.note)}</p>` : ""}<p>${Object.entries(
sample.strata || {},
)
.map(([k, v]) => `${esc(k)}:${esc(v)}`)
.join(
" · ",
)}</p><div class="link-list">${sample.source_page ? external("来源图册", sample.source_page) : ""}${sample.evidence_url ? external("素材记录", sample.evidence_url) : ""}${external("原图", sample.rgb)}</div>${sample.license ? `<p>${esc(sample.license)}</p>` : ""}`;
root
.querySelectorAll("[data-preview]")
.forEach((button) =>
button.setAttribute(
"aria-pressed",
Number(button.dataset.preview) === index,
),
);
}
find("prev").onclick = () => {
index = (index + previews.length - 1) % previews.length;
show();
};
find("next").onclick = () => {
index = (index + 1) % previews.length;
show();
};
find("sample").onchange = (event) => {
index = Number(event.target.value);
show();
};
find("mode").onchange = annotation;
find("alpha").oninput = (event) => {
find("overlay").style.opacity = event.target.value / 100;
find("alpha-value").textContent = `${event.target.value}%`;
};
find("zoom").oninput = viewport;
find("hand").onclick = () => {
box = previews[index].crop;
find("zoom").value = 1;
viewport();
};
find("reset").onclick = () => {
box = [0, 0, previews[index].width, previews[index].height];
find("zoom").value = 1;
viewport();
};
root.querySelectorAll("[data-preview]").forEach((button) => {
button.onclick = () => {
index = Number(button.dataset.preview);
show();
};
});
show();
}
export function figuresHTML(id, context) {
const entry = context.figures[id];
if (!entry?.figures?.length) return "";
return section(
"采集与标注",
`${entry.note ? `<p class="source-note">${esc(entry.note)}</p>` : ""}<div class="official-figures">${entry.figures.map((figure) => `<figure><h3>${esc(figure.title)}</h3><div class="figure-images">${figure.images.map((img) => `<a href="${esc(img.src)}" target="_blank" rel="noopener"><img src="${esc(img.src)}" alt="${esc(figure.title)}" width="${img.width}" height="${img.height}" loading="lazy"></a>`).join("")}</div><figcaption>${esc(figure.caption)} ${external(figure.source_label, figure.source_url)}</figcaption><details><summary>原文与署名</summary><p>${esc(figure.original_caption)}</p><p>${esc(figure.attribution)}</p></details></figure>`).join("")}</div>`,
);
}
export function videoHTML(id, context) {
const list = context.videos[id] || [];
if (!list.length) return "";
return section(
"动作序列",
`<select id="clip-select" aria-label="视频序列">${list.map((v, i) => `<option value="${i}">${esc(v.recording_id)} · ${esc(v.source_tag)}</option>`).join("")}</select><video id="clip-player" controls playsinline preload="none"></video><p id="clip-note" class="source-note"></p>`,
);
}
export function bindVideo(id, context) {
const select = document.getElementById("clip-select");
if (!select) return;
const player = document.getElementById("clip-player");
const update = () => {
const video = context.videos[id][Number(select.value)];
player.pause();
player.src = video.url;
document.getElementById("clip-note").textContent =
`${video.source_tag} · ${video.mano_source} · 来源帧 ${video.source_first_frame}–${video.source_last_frame} · ${video.duration_s}秒`;
};
select.onchange = update;
update();
}