EgoNetDatasetResearch / src /catalog.js
DarthSoluke's picture
Refactor the atlas into a dataset-first modular frontend
831819e verified
Raw History Blame Contribute Delete
11.8 kB
import {
esc,
icon,
external,
section,
table,
empty,
fact,
tier,
status,
thumbnail,
enhance,
} from "./ui.js";
import {
previewHTML,
figuresHTML,
videoHTML,
bindPreview,
bindVideo,
} from "./preview.js";
export const defaults = {
q: "",
source: "",
method: "",
tier: "",
status: "",
route: "",
preview: false,
sort: "name",
};
const tabs = [
["overview", "概况"],
["annotation", "采集与标注"],
["access", "数据访问"],
["conversion", "SOMA转换"],
];
function options(list, value, label) {
return `<option value="">${label}</option>${list.map((item) => `<option value="${esc(item.id)}" ${item.id === value ? "selected" : ""}>${esc(item.name)}</option>`).join("")}`;
}
export function filterDatasets(context, filters) {
const q = filters.q.trim().toLowerCase();
return context.data.datasets
.filter((d) => {
if (
["source", "method", "tier", "status", "route"].some(
(key) => filters[key] && d[key] !== filters[key],
)
)
return false;
if (filters.preview && !context.previews[d.id]?.length) return false;
const haystack = [
d.name,
d.camera,
d.gt,
d.fills,
d.scale,
d.subjects,
...(d.tags || []),
]
.join(" ")
.toLowerCase();
return !q || haystack.includes(q);
})
.sort((a, b) => {
if (filters.sort === "year")
return (
(Number(b.year?.match(/\d{4}/)?.[0]) || 0) -
(Number(a.year?.match(/\d{4}/)?.[0]) || 0) ||
a.name.localeCompare(b.name)
);
if (filters.sort === "status")
return (
context.meta.status[a.status].order -
context.meta.status[b.status].order || a.name.localeCompare(b.name)
);
return a.name.localeCompare(b.name, "en", { sensitivity: "base" });
});
}
export function catalogView(context, filters) {
return `<section class="view catalog-view"><header class="page-heading"><h1>数据集 <span class="heading-count">${context.data.datasets.length}</span></h1><div class="heading-actions"><a href="#overview" class="text-link"><i data-lucide="grid-2x2" aria-hidden="true"></i>数据分布</a></div></header>
<form id="catalog-filters" role="search" class="catalog-filters">
<div class="search-field"><i data-lucide="search" aria-hidden="true"></i><input id="dataset-search" type="search" aria-label="搜索数据集" placeholder="搜索数据集、相机或标注" value="${esc(filters.q)}"></div>
<div class="filter-row">
<select data-filter="source" aria-label="数据来源">${options(context.data.meta.sources, filters.source, "全部来源")}</select>
<select data-filter="method" aria-label="标注方法">${options(context.data.meta.methods, filters.method, "全部标注")}</select>
<select data-filter="tier" aria-label="Shape真值层级">${options(
context.data.meta.tiers.map((t) => ({
...t,
name: `${t.id === "-" ? "—" : t.id} ${t.name}`,
})),
filters.tier,
"全部Shape层级",
)}</select>
<select data-filter="status" aria-label="使用状态">${options(context.data.meta.statuses, filters.status, "全部状态")}</select>
${filters.route ? `<select data-filter="route" aria-label="SOMA转换路线">${options(context.data.meta.routes, filters.route, "全部转换路线")}</select>` : ""}
<label class="checkbox"><input data-filter="preview" type="checkbox" ${filters.preview ? "checked" : ""}>有样例</label>
${icon("rotate-ccw", "重置筛选", "data-clear-filters")}
</div>
</form>
<div class="results-toolbar"><span id="result-count" role="status" aria-live="polite"></span><select id="dataset-sort" aria-label="排序"><option value="name">名称</option><option value="year">年份</option><option value="status">使用状态</option></select></div>
<div class="dataset-list"><div class="dataset-columns" aria-hidden="true"><span>数据集</span><span>数据来源</span><span>标注</span><span>规模</span><span>状态</span></div><div id="dataset-results"></div></div>
</section>`;
}
export function bindCatalog(context, filters) {
function update() {
const datasets = filterDatasets(context, filters);
document.getElementById("result-count").textContent =
`${datasets.length} / ${context.data.datasets.length}`;
document.getElementById("dataset-results").innerHTML = datasets.length
? datasets
.map(
(d) => `<a class="dataset-row" href="#ds-${d.id}">
<div class="dataset-name">${thumbnail(d, context)}<div><h2>${esc(d.name)}</h2><span class="dataset-year">${esc(d.year)}</span></div></div>
<div class="dataset-source"><span>${esc(context.meta.source[d.source].name)}</span><span class="muted">${esc(context.meta.method[d.method].short)}</span></div>
<div class="dataset-annotation"><span>${esc(d.gt)}</span>${tier(d.tier, context.meta)}</div>
<div class="dataset-scale"><span>${esc(d.scale || "—")}</span><span class="muted">${esc(d.subjects)}</span></div>
<div class="dataset-status">${status(d.status, context.meta)}</div>
</a>`,
)
.join("")
: empty("没有匹配的数据集", true);
const params = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v && v !== defaults[k]) params.set(k, String(v));
});
history.replaceState(
null,
"",
`#catalog${params.size ? "?" + params : ""}`,
);
document.getElementById("dataset-sort").value = filters.sort;
enhance();
document.querySelectorAll("[data-clear-filters]").forEach((button) => {
button.onclick = reset;
});
}
function reset() {
Object.assign(filters, defaults);
document.getElementById("dataset-search").value = "";
document.querySelectorAll("[data-filter]").forEach((el) => {
if (el.type === "checkbox") el.checked = false;
else el.value = "";
});
update();
}
document.getElementById("catalog-filters").onsubmit = (event) =>
event.preventDefault();
document.getElementById("dataset-search").oninput = (event) => {
filters.q = event.target.value;
update();
};
document.querySelectorAll("[data-filter]").forEach((el) => {
el.onchange = () => {
filters[el.dataset.filter] =
el.type === "checkbox" ? el.checked : el.value;
update();
};
});
document.getElementById("dataset-sort").onchange = (event) => {
filters.sort = event.target.value;
update();
};
update();
}
export function overviewView(context) {
const { data, meta } = context;
const matrix = data.meta.methods.map(
(method) =>
`<tr><th scope="row"><a href="#methods/${method.id}">${esc(method.short)}</a></th>${data.meta.sources
.map((source) => {
const items = data.datasets.filter(
(d) => d.method === method.id && d.source === source.id,
);
return `<td>${items.map((d) => `<a href="#ds-${d.id}" class="matrix-link">${esc(d.name)}</a>`).join("") || '<span class="muted">—</span>'}</td>`;
})
.join("")}</tr>`,
);
return `<section class="view"><header class="page-heading"><h1>数据分布</h1><a href="#catalog" class="text-link">${data.datasets.length} 个数据集<i data-lucide="arrow-up-right" aria-hidden="true"></i></a></header>
<div class="source-counts">${data.meta.sources.map((source) => `<a href="#catalog?source=${source.id}"><b>${data.datasets.filter((d) => d.source === source.id).length}</b><span>${esc(source.name)}</span></a>`).join("")}</div>
${section("来源 × 标注", table(["标注方法", ...data.meta.sources.map((s) => s.name)], matrix, "matrix"))}
${section("Shape真值", `<dl class="tier-reference">${data.meta.tiers.map((t) => `<div><dt>${tier(t.id, meta)}<strong>${esc(t.name)}</strong></dt><dd>${esc(t.desc)}</dd><a href="#catalog?tier=${encodeURIComponent(t.id)}">${data.datasets.filter((d) => d.tier === t.id).length}<i data-lucide="arrow-up-right" aria-hidden="true"></i></a></div>`).join("")}</dl>`)}
${section(
"优先接入",
`<div class="recommendations">${data.datasets
.filter((d) => d.status === "p0")
.map(
(d) =>
`<a href="#ds-${d.id}">${thumbnail(d, context)}<h3>${esc(d.name)}</h3><p>${esc(d.fills)}</p></a>`,
)
.join("")}</div>`,
)}
</section>`;
}
export function detailView(id, tab, context) {
const dataset = context.byId[id];
if (!dataset)
return empty("未找到这个数据集") + '<a href="#catalog">返回数据集</a>';
const { meta } = context,
method = meta.method[dataset.method],
route = meta.route[dataset.route];
const links = [...(dataset.links || []), ...(dataset.team || [])];
const body =
tab === "annotation"
? `${section(method.name, `<ol class="flow">${method.steps.map((s) => `<li>${esc(s)}</li>`).join("")}</ol><dl class="facts">${fact("可信度", esc(method.trust))}${fact("常见问题", esc(method.issues))}</dl><a href="#methods/${method.id}" class="text-link">相关数据集<i data-lucide="arrow-up-right" aria-hidden="true"></i></a>`)}${figuresHTML(id, context)}`
: tab === "access"
? section(
"来源",
`<ul class="resource-links">${links.map((l) => `<li>${external(l.label, l.url)}</li>`).join("")}</ul>${!links.length ? '<p class="muted">暂无已登记的访问链接。</p>' : ""}`,
)
: tab === "conversion"
? `${section(route.name, `<dl class="facts">${fact("原始标注", esc(route.from))}${fact("方法", esc(route.how))}${fact("Shape监督", route.shape ? "可用;仍须核对来源与拟合残差" : "不使用")}${fact("层级", tier(dataset.tier, meta) + " " + esc(meta.tier[dataset.tier].name))}</dl><a class="text-link" href="#soma/${route.id}">全部转换路线<i data-lucide="arrow-up-right" aria-hidden="true"></i></a>`)}${id === "egorenderer" ? '<p class="notice">拟合MANO/SOMA是辅助标注,不参与原生手部渲染。</p>' : ""}`
: `${previewHTML(dataset, context)}${section("数据规格", `<dl class="facts">${fact("视角与相机", esc(dataset.camera))}${fact("受试者", esc(dataset.subjects))}${fact("规模", esc(dataset.scale))}${fact("原始标注", esc(dataset.gt))}${fact("Shape来源", tier(dataset.tier, meta) + " " + esc(meta.tier[dataset.tier].name))}</dl>${dataset.risk ? `<p class="notice">${esc(dataset.risk)}</p>` : ""}`)}${videoHTML(id, context)}${!context.previews[id]?.length ? figuresHTML(id, context) : ""}`;
return `<section class="view detail-view"><a class="breadcrumb" href="#catalog"><i data-lucide="arrow-left" aria-hidden="true"></i>数据集</a><header class="detail-heading"><div><h1>${esc(dataset.name)}</h1><p>${esc(dataset.fills)}</p></div><div class="detail-meta">${status(dataset.status, meta)}<span>${esc(dataset.year)}</span></div></header>
${id === "egorenderer" ? '<div class="dataset-tools"><a href="#synthetic/progress">生产进度<i data-lucide="arrow-up-right" aria-hidden="true"></i></a><a href="#synthetic/reading">读取方法<i data-lucide="arrow-up-right" aria-hidden="true"></i></a><a href="#synthetic/versions">版本<i data-lucide="arrow-up-right" aria-hidden="true"></i></a></div>' : ""}
<nav class="section-tabs" aria-label="数据集详情">${tabs.map(([key, name]) => `<a href="#ds-${id}/${key}" ${key === tab ? 'aria-current="page"' : ""}>${name}</a>`).join("")}</nav>${body}
</section>`;
}
export function bindDetail(id, context) {
if (!context.byId[id]) return;
bindPreview(context.byId[id], context);
bindVideo(id, context);
}