160 lines
5.3 KiB
HTML
160 lines
5.3 KiB
HTML
<!doctype html>
|
||||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>AF3 Mol* 三维查看沙箱</title>
|
|||
|
|
<link rel="icon" href="data:,">
|
|||
|
|
<link rel="stylesheet" href="/static/vendor/molstar/molstar.css">
|
|||
|
|
<link rel="stylesheet" href="/static/af3-sandbox.css">
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div id="app">
|
|||
|
|
<main class="sandbox">
|
|||
|
|
<header class="toolbar">
|
|||
|
|
<div class="title">AF3 Mol* 三维结构查看器</div>
|
|||
|
|
|
|||
|
|
<label class="control">
|
|||
|
|
<span>结构</span>
|
|||
|
|
<select v-model="selected" :disabled="loading || structures.length === 0" @change="loadSelected">
|
|||
|
|
<option v-for="item in structures" :key="item.name" :value="item.name">
|
|||
|
|
{{ item.name }}
|
|||
|
|
</option>
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<label class="control">
|
|||
|
|
<span>表示</span>
|
|||
|
|
<select v-model="representation" :disabled="loading || !selected" @change="loadSelected">
|
|||
|
|
<option value="cartoon">Cartoon</option>
|
|||
|
|
<option value="ball_and_stick">Ball and stick</option>
|
|||
|
|
<option value="surface">Surface</option>
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<button type="button" :disabled="loading || !selected" @click="loadSelected">重新加载</button>
|
|||
|
|
<button type="button" :disabled="!viewerReady" @click="resetCamera">重置视角</button>
|
|||
|
|
<a
|
|||
|
|
class="download"
|
|||
|
|
:class="{ disabled: !selected }"
|
|||
|
|
:href="downloadUrl"
|
|||
|
|
:aria-disabled="!selected"
|
|||
|
|
:download="selected || null"
|
|||
|
|
@click="guardDownload"
|
|||
|
|
>下载 CIF</a>
|
|||
|
|
<span class="status" :class="{ error: error }">{{ error || status }}</span>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<section class="viewer-shell">
|
|||
|
|
<div id="molstar-viewer"></div>
|
|||
|
|
<div v-if="fatalError" class="empty-state error">{{ fatalError }}</div>
|
|||
|
|
</section>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script src="/shared-static/cdn/vue.global.prod.js"></script>
|
|||
|
|
<script src="/static/vendor/molstar/molstar.js"></script>
|
|||
|
|
<script src="/static/af3-viewer.js"></script>
|
|||
|
|
<script>
|
|||
|
|
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
|
|||
|
|
|
|||
|
|
createApp({
|
|||
|
|
setup() {
|
|||
|
|
const structures = ref([]);
|
|||
|
|
const selected = ref('');
|
|||
|
|
const representation = ref('cartoon');
|
|||
|
|
const status = ref('初始化中');
|
|||
|
|
const error = ref('');
|
|||
|
|
const fatalError = ref('');
|
|||
|
|
const loading = ref(false);
|
|||
|
|
const viewerReady = ref(false);
|
|||
|
|
let viewer = null;
|
|||
|
|
|
|||
|
|
const downloadUrl = computed(() =>
|
|||
|
|
selected.value ? `/api/structures/${encodeURIComponent(selected.value)}` : '#',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
function hasWebGL() {
|
|||
|
|
const canvas = document.createElement('canvas');
|
|||
|
|
return Boolean(
|
|||
|
|
window.WebGL2RenderingContext && canvas.getContext('webgl2')
|
|||
|
|
|| window.WebGLRenderingContext && canvas.getContext('webgl'),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadSelected() {
|
|||
|
|
if (!viewer || !selected.value) return;
|
|||
|
|
loading.value = true;
|
|||
|
|
error.value = '';
|
|||
|
|
status.value = '加载中';
|
|||
|
|
try {
|
|||
|
|
const url = `/api/structures/${encodeURIComponent(selected.value)}`;
|
|||
|
|
await viewer.load(url, representation.value);
|
|||
|
|
status.value = '加载完成';
|
|||
|
|
} catch (loadError) {
|
|||
|
|
console.error(loadError);
|
|||
|
|
error.value = `加载失败:${loadError.message || loadError}`;
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetCamera() {
|
|||
|
|
viewer?.resetCamera();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function guardDownload(event) {
|
|||
|
|
if (!selected.value) event.preventDefault();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
try {
|
|||
|
|
if (!hasWebGL()) {
|
|||
|
|
throw new Error('当前浏览器未启用 WebGL,请开启硬件加速后重试');
|
|||
|
|
}
|
|||
|
|
viewer = await window.Af3MolstarViewer.create('molstar-viewer');
|
|||
|
|
viewerReady.value = true;
|
|||
|
|
const response = await fetch('/api/structures');
|
|||
|
|
if (!response.ok) throw new Error(`结构列表请求失败(${response.status})`);
|
|||
|
|
const body = await response.json();
|
|||
|
|
structures.value = body.structures || [];
|
|||
|
|
if (!structures.value.length) {
|
|||
|
|
throw new Error('数据目录中没有可用的 CIF/mmCIF 文件');
|
|||
|
|
}
|
|||
|
|
const preferred = structures.value.find(
|
|||
|
|
(item) => item.name === 'gcn4_dimer_demo_model.cif',
|
|||
|
|
);
|
|||
|
|
selected.value = (preferred || structures.value[0]).name;
|
|||
|
|
await loadSelected();
|
|||
|
|
} catch (mountError) {
|
|||
|
|
console.error(mountError);
|
|||
|
|
fatalError.value = mountError.message || String(mountError);
|
|||
|
|
error.value = fatalError.value;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
viewer?.dispose();
|
|||
|
|
viewer = null;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
structures,
|
|||
|
|
selected,
|
|||
|
|
representation,
|
|||
|
|
status,
|
|||
|
|
error,
|
|||
|
|
fatalError,
|
|||
|
|
loading,
|
|||
|
|
viewerReady,
|
|||
|
|
downloadUrl,
|
|||
|
|
loadSelected,
|
|||
|
|
resetCamera,
|
|||
|
|
guardDownload,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
}).mount('#app');
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|