6456 lines
270 KiB
HTML
6456 lines
270 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<title>NUPACK</title>
|
||
<style>
|
||
:root,
|
||
[data-theme="linen"] {
|
||
--bg: #f3efe4;
|
||
--panel: rgba(255, 252, 244, 0.92);
|
||
--panel-strong: #fffdf7;
|
||
--card: rgba(255, 255, 255, 0.74);
|
||
--card-strong: rgba(255, 255, 255, 0.88);
|
||
--button-secondary: rgba(255, 255, 255, 0.84);
|
||
--input-bg: #fffdf7;
|
||
--ink: #15201b;
|
||
--muted: #58645c;
|
||
--line: rgba(21, 32, 27, 0.14);
|
||
--accent: #1c7c54;
|
||
--accent-soft: #d9f2df;
|
||
--accent-strong: #0f5d3c;
|
||
--warn: #8f3d16;
|
||
--shadow: 0 18px 50px rgba(21, 32, 27, 0.12);
|
||
--chart-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 243, 234, 0.92) 100%);
|
||
--chart-grid: rgba(21, 32, 27, 0.16);
|
||
--chart-axis: #15201b;
|
||
--chart-label: #58645c;
|
||
--chart-bar: #cf4b30;
|
||
--chart-bar-soft: #f1b49f;
|
||
--chart-text-strong: #ffffff;
|
||
--chart-name: #2540a8;
|
||
--bg-glow-a: rgba(28, 124, 84, 0.22);
|
||
--bg-glow-b: rgba(220, 144, 78, 0.18);
|
||
--bg-start: #f7f1e6;
|
||
--bg-end: #ece7db;
|
||
}
|
||
|
||
[data-theme="graphite"] {
|
||
--bg: #121716;
|
||
--panel: rgba(21, 29, 26, 0.92);
|
||
--panel-strong: #101815;
|
||
--card: rgba(28, 36, 33, 0.9);
|
||
--card-strong: rgba(24, 31, 29, 0.96);
|
||
--button-secondary: rgba(26, 35, 32, 0.94);
|
||
--input-bg: #141d1a;
|
||
--ink: #e3f2eb;
|
||
--muted: #9bb1a7;
|
||
--line: rgba(163, 207, 187, 0.16);
|
||
--accent: #42c78d;
|
||
--accent-soft: rgba(66, 199, 141, 0.14);
|
||
--accent-strong: #7ff3bf;
|
||
--warn: #ff9d70;
|
||
--shadow: 0 20px 56px rgba(0, 0, 0, 0.35);
|
||
--chart-surface: linear-gradient(180deg, rgba(22, 28, 26, 0.98) 0%, rgba(17, 23, 21, 0.96) 100%);
|
||
--chart-grid: rgba(160, 206, 186, 0.16);
|
||
--chart-axis: #d9efe6;
|
||
--chart-label: #a7beb4;
|
||
--chart-bar: #46c494;
|
||
--chart-bar-soft: #2f5e4d;
|
||
--chart-text-strong: #112019;
|
||
--chart-name: #9bc3ff;
|
||
--bg-glow-a: rgba(66, 199, 141, 0.15);
|
||
--bg-glow-b: rgba(64, 129, 255, 0.12);
|
||
--bg-start: #0f1413;
|
||
--bg-end: #151d1a;
|
||
}
|
||
|
||
[data-theme="tide"] {
|
||
--bg: #eef4f8;
|
||
--panel: rgba(247, 251, 255, 0.92);
|
||
--panel-strong: #ffffff;
|
||
--card: rgba(255, 255, 255, 0.8);
|
||
--card-strong: rgba(255, 255, 255, 0.92);
|
||
--button-secondary: rgba(255, 255, 255, 0.9);
|
||
--input-bg: #ffffff;
|
||
--ink: #102236;
|
||
--muted: #5c7186;
|
||
--line: rgba(16, 34, 54, 0.12);
|
||
--accent: #146c94;
|
||
--accent-soft: rgba(20, 108, 148, 0.1);
|
||
--accent-strong: #0d5a7e;
|
||
--warn: #ab5b1f;
|
||
--shadow: 0 18px 50px rgba(16, 34, 54, 0.12);
|
||
--chart-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(237, 246, 252, 0.95) 100%);
|
||
--chart-grid: rgba(16, 34, 54, 0.12);
|
||
--chart-axis: #102236;
|
||
--chart-label: #597389;
|
||
--chart-bar: #1f87b6;
|
||
--chart-bar-soft: #9dd6ef;
|
||
--chart-text-strong: #ffffff;
|
||
--chart-name: #0f487e;
|
||
--bg-glow-a: rgba(49, 144, 188, 0.18);
|
||
--bg-glow-b: rgba(110, 182, 147, 0.16);
|
||
--bg-start: #f4f8fb;
|
||
--bg-end: #e6eef4;
|
||
}
|
||
|
||
[data-theme="ember"] {
|
||
--bg: #f6efe6;
|
||
--panel: rgba(255, 249, 243, 0.92);
|
||
--panel-strong: #fffdf9;
|
||
--card: rgba(255, 255, 255, 0.78);
|
||
--card-strong: rgba(255, 255, 255, 0.9);
|
||
--button-secondary: rgba(255, 255, 255, 0.88);
|
||
--input-bg: #fffdf9;
|
||
--ink: #2d1f18;
|
||
--muted: #7b6458;
|
||
--line: rgba(45, 31, 24, 0.12);
|
||
--accent: #b85c38;
|
||
--accent-soft: rgba(184, 92, 56, 0.12);
|
||
--accent-strong: #8f4124;
|
||
--warn: #99301e;
|
||
--shadow: 0 18px 50px rgba(45, 31, 24, 0.12);
|
||
--chart-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 240, 232, 0.94) 100%);
|
||
--chart-grid: rgba(45, 31, 24, 0.12);
|
||
--chart-axis: #2d1f18;
|
||
--chart-label: #7f6258;
|
||
--chart-bar: #cc6f45;
|
||
--chart-bar-soft: #f0c2ae;
|
||
--chart-text-strong: #ffffff;
|
||
--chart-name: #8e3f27;
|
||
--bg-glow-a: rgba(184, 92, 56, 0.18);
|
||
--bg-glow-b: rgba(219, 168, 91, 0.14);
|
||
--bg-start: #faf2ea;
|
||
--bg-end: #efe1d2;
|
||
}
|
||
|
||
[data-theme="sage"] {
|
||
--bg: #eef1ea;
|
||
--panel: rgba(248, 251, 244, 0.92);
|
||
--panel-strong: #fcfdf8;
|
||
--card: rgba(255, 255, 255, 0.78);
|
||
--card-strong: rgba(255, 255, 255, 0.9);
|
||
--button-secondary: rgba(255, 255, 255, 0.88);
|
||
--input-bg: #fcfdf8;
|
||
--ink: #1d2b22;
|
||
--muted: #617265;
|
||
--line: rgba(29, 43, 34, 0.11);
|
||
--accent: #5a7d56;
|
||
--accent-soft: rgba(90, 125, 86, 0.12);
|
||
--accent-strong: #40603d;
|
||
--warn: #8d5434;
|
||
--shadow: 0 18px 50px rgba(33, 44, 35, 0.1);
|
||
--chart-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(241, 246, 236, 0.95) 100%);
|
||
--chart-grid: rgba(29, 43, 34, 0.11);
|
||
--chart-axis: #1d2b22;
|
||
--chart-label: #647368;
|
||
--chart-bar: #6d8f5d;
|
||
--chart-bar-soft: #d6e3c9;
|
||
--chart-text-strong: #ffffff;
|
||
--chart-name: #31544a;
|
||
--bg-glow-a: rgba(107, 146, 95, 0.16);
|
||
--bg-glow-b: rgba(179, 166, 97, 0.14);
|
||
--bg-start: #f4f7ef;
|
||
--bg-end: #e6ece2;
|
||
}
|
||
|
||
[data-theme="harbor"] {
|
||
--bg: #edf3f5;
|
||
--panel: rgba(246, 251, 252, 0.92);
|
||
--panel-strong: #fbfeff;
|
||
--card: rgba(255, 255, 255, 0.8);
|
||
--card-strong: rgba(255, 255, 255, 0.92);
|
||
--button-secondary: rgba(255, 255, 255, 0.9);
|
||
--input-bg: #fbfeff;
|
||
--ink: #132733;
|
||
--muted: #607785;
|
||
--line: rgba(19, 39, 51, 0.1);
|
||
--accent: #28748f;
|
||
--accent-soft: rgba(40, 116, 143, 0.11);
|
||
--accent-strong: #18566c;
|
||
--warn: #985331;
|
||
--shadow: 0 20px 54px rgba(19, 39, 51, 0.1);
|
||
--chart-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(236, 246, 249, 0.95) 100%);
|
||
--chart-grid: rgba(19, 39, 51, 0.1);
|
||
--chart-axis: #132733;
|
||
--chart-label: #647986;
|
||
--chart-bar: #3a8ea8;
|
||
--chart-bar-soft: #b8deea;
|
||
--chart-text-strong: #ffffff;
|
||
--chart-name: #1e5a80;
|
||
--bg-glow-a: rgba(58, 142, 168, 0.16);
|
||
--bg-glow-b: rgba(103, 160, 145, 0.14);
|
||
--bg-start: #f5fafb;
|
||
--bg-end: #e4edf0;
|
||
}
|
||
|
||
[data-theme="studio"] {
|
||
--bg: #1a1820;
|
||
--panel: rgba(28, 27, 35, 0.92);
|
||
--panel-strong: #191922;
|
||
--card: rgba(35, 34, 44, 0.9);
|
||
--card-strong: rgba(29, 29, 39, 0.96);
|
||
--button-secondary: rgba(36, 35, 46, 0.94);
|
||
--input-bg: #1c1c25;
|
||
--ink: #efe9e2;
|
||
--muted: #b7afa6;
|
||
--line: rgba(239, 233, 226, 0.1);
|
||
--accent: #c36f3c;
|
||
--accent-soft: rgba(195, 111, 60, 0.14);
|
||
--accent-strong: #ebb07c;
|
||
--warn: #ffb08a;
|
||
--shadow: 0 22px 60px rgba(0, 0, 0, 0.34);
|
||
--chart-surface: linear-gradient(180deg, rgba(31, 30, 39, 0.98) 0%, rgba(24, 24, 31, 0.96) 100%);
|
||
--chart-grid: rgba(239, 233, 226, 0.1);
|
||
--chart-axis: #eee8e1;
|
||
--chart-label: #c6beb4;
|
||
--chart-bar: #d47b47;
|
||
--chart-bar-soft: #5a3f35;
|
||
--chart-text-strong: #fff7f0;
|
||
--chart-name: #f1c08b;
|
||
--bg-glow-a: rgba(195, 111, 60, 0.14);
|
||
--bg-glow-b: rgba(89, 122, 166, 0.1);
|
||
--bg-start: #18161d;
|
||
--bg-end: #221f29;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
|
||
color: var(--ink);
|
||
background:
|
||
radial-gradient(circle at top left, var(--bg-glow-a), transparent 28%),
|
||
radial-gradient(circle at bottom right, var(--bg-glow-b), transparent 22%),
|
||
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
|
||
}
|
||
|
||
.shell {
|
||
max-width: 1360px;
|
||
margin: 0 auto;
|
||
padding: 28px 18px 44px;
|
||
}
|
||
|
||
.hero {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.hero-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.hero h1 {
|
||
margin: 0;
|
||
font-size: clamp(32px, 4.2vw, 64px);
|
||
line-height: 0.95;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
|
||
.hero p {
|
||
margin: 0;
|
||
max-width: 820px;
|
||
color: var(--muted);
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.queue-panel {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.queue-panel .metric {
|
||
padding: 12px 14px;
|
||
border-radius: 16px;
|
||
background: var(--card);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
}
|
||
|
||
.queue-panel .metric strong {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(360px, 520px) minmax(0, 1fr);
|
||
gap: 18px;
|
||
align-items: start;
|
||
min-height: calc(100vh - 72px);
|
||
}
|
||
|
||
[data-layout="editorial"] .shell {
|
||
max-width: 1500px;
|
||
}
|
||
|
||
[data-layout="editorial"] .layout {
|
||
grid-template-columns: minmax(320px, 430px) minmax(0, 1fr);
|
||
gap: 24px;
|
||
}
|
||
|
||
[data-layout="dashboard"] .shell {
|
||
max-width: 1580px;
|
||
}
|
||
|
||
[data-layout="dashboard"] .layout {
|
||
grid-template-columns: minmax(420px, 600px) minmax(0, 1fr);
|
||
gap: 22px;
|
||
}
|
||
|
||
[data-layout="stacked"] .shell {
|
||
max-width: 1240px;
|
||
}
|
||
|
||
[data-layout="stacked"] .layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
[data-layout="stacked"] .control-panel {
|
||
position: static;
|
||
}
|
||
|
||
.panel {
|
||
background: var(--panel);
|
||
backdrop-filter: blur(14px);
|
||
border: 1px solid var(--line);
|
||
border-radius: 22px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.panel-inner {
|
||
padding: 18px;
|
||
}
|
||
|
||
.control-panel {
|
||
position: sticky;
|
||
top: 18px;
|
||
}
|
||
|
||
.results-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.section {
|
||
padding-bottom: 18px;
|
||
margin-bottom: 18px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.section:last-child {
|
||
margin-bottom: 0;
|
||
padding-bottom: 0;
|
||
border-bottom: 0;
|
||
}
|
||
|
||
.section h2 {
|
||
margin: 0 0 12px;
|
||
font-size: 18px;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.grid.cols-2 {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.toolbar,
|
||
.actions,
|
||
.strand-actions,
|
||
.summary-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
label {
|
||
display: grid;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
input,
|
||
select,
|
||
textarea,
|
||
button {
|
||
font: inherit;
|
||
}
|
||
|
||
input,
|
||
select,
|
||
textarea {
|
||
width: 100%;
|
||
padding: 11px 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(21, 32, 27, 0.12);
|
||
background: var(--input-bg);
|
||
color: var(--ink);
|
||
}
|
||
|
||
input::placeholder,
|
||
textarea::placeholder {
|
||
color: var(--muted);
|
||
}
|
||
|
||
textarea {
|
||
min-height: 120px;
|
||
resize: vertical;
|
||
}
|
||
|
||
button {
|
||
border: 0;
|
||
border-radius: 999px;
|
||
padding: 11px 18px;
|
||
cursor: pointer;
|
||
transition: transform 120ms ease, opacity 120ms ease;
|
||
}
|
||
|
||
button:hover {
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
button:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
transform: none;
|
||
}
|
||
|
||
button:disabled:hover {
|
||
transform: none;
|
||
}
|
||
|
||
button.primary {
|
||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||
color: white;
|
||
}
|
||
|
||
button.secondary {
|
||
background: var(--button-secondary);
|
||
color: var(--ink);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
}
|
||
|
||
button.danger {
|
||
background: linear-gradient(135deg, #c73528 0%, #8f1f18 100%);
|
||
color: #fff;
|
||
border: 1px solid rgba(143, 31, 24, 0.22);
|
||
}
|
||
|
||
.pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
background: var(--accent-soft);
|
||
color: var(--accent-strong);
|
||
}
|
||
|
||
.pill.error {
|
||
background: rgba(143, 61, 22, 0.12);
|
||
color: var(--warn);
|
||
}
|
||
|
||
.check-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
background: var(--card);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.check input {
|
||
width: auto;
|
||
margin: 0;
|
||
}
|
||
|
||
.strand-list,
|
||
.complex-editor,
|
||
.results {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
.strand-card,
|
||
.result-card {
|
||
padding: 16px;
|
||
border-radius: 18px;
|
||
background: var(--card);
|
||
border: 1px solid rgba(21, 32, 27, 0.09);
|
||
}
|
||
|
||
.strand-meta-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 1fr) 100px auto;
|
||
gap: 10px;
|
||
align-items: end;
|
||
}
|
||
|
||
.sequence-field {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.sequence-field textarea {
|
||
min-height: 88px;
|
||
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.result-card h3 {
|
||
margin: 0 0 10px;
|
||
font-size: 17px;
|
||
}
|
||
|
||
.result-card-header {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.result-card-header h3 {
|
||
margin: 0;
|
||
}
|
||
|
||
.meta-line {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.meta-chip {
|
||
padding: 7px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(28, 124, 84, 0.08);
|
||
border: 1px solid rgba(28, 124, 84, 0.12);
|
||
color: var(--accent-strong);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.kv {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.kv div {
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
background: rgba(28, 124, 84, 0.07);
|
||
min-width: 0;
|
||
}
|
||
|
||
.kv strong {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.summary-bar div {
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
background: var(--card);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
min-width: 140px;
|
||
}
|
||
|
||
.metric-value {
|
||
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||
font-size: 14px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.result-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.result-grid .result-card.full {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
th,
|
||
td {
|
||
text-align: left;
|
||
padding: 10px 8px;
|
||
border-bottom: 1px solid rgba(21, 32, 27, 0.09);
|
||
font-size: 13px;
|
||
}
|
||
|
||
pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
padding: 14px;
|
||
border-radius: 16px;
|
||
background: color-mix(in srgb, var(--panel-strong) 22%, #0f1613);
|
||
color: color-mix(in srgb, var(--ink) 18%, #f0fff6);
|
||
overflow: auto;
|
||
max-height: 440px;
|
||
}
|
||
|
||
details {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
details summary {
|
||
cursor: pointer;
|
||
color: var(--accent-strong);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
canvas {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(21, 32, 27, 0.09);
|
||
background: var(--card-strong);
|
||
}
|
||
|
||
.svg-wrap {
|
||
margin: 12px 0;
|
||
padding: 12px;
|
||
border-radius: 16px;
|
||
background: #fff;
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
overflow: hidden;
|
||
min-height: 220px;
|
||
max-height: 560px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: zoom-in;
|
||
}
|
||
|
||
.svg-wrap svg {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 520px;
|
||
width: auto;
|
||
height: auto;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.structure-stack {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
.view-switch {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin: 10px 0 14px;
|
||
}
|
||
|
||
.view-switch label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.view-panel.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.force-shell {
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(21, 32, 27, 0.09);
|
||
background: var(--card-strong);
|
||
min-height: 420px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.force-toolbar {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid rgba(21, 32, 27, 0.08);
|
||
background:
|
||
radial-gradient(circle at top left, rgba(28, 124, 84, 0.08), transparent 42%),
|
||
linear-gradient(180deg, color-mix(in srgb, var(--card-strong) 88%, white), var(--card-strong));
|
||
}
|
||
|
||
.force-toolbar-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.force-toolbar-top strong {
|
||
color: var(--ink);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.force-toolbar-top span {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.force-summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.force-controls {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.force-controls button {
|
||
padding: 7px 12px;
|
||
}
|
||
|
||
.force-canvas {
|
||
position: relative;
|
||
width: 100%;
|
||
min-height: 420px;
|
||
background:
|
||
radial-gradient(circle at center, rgba(28, 124, 84, 0.03), transparent 48%),
|
||
linear-gradient(180deg, color-mix(in srgb, var(--card-strong) 92%, white), color-mix(in srgb, var(--card-strong) 92%, transparent));
|
||
}
|
||
|
||
.force-canvas::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
background-image:
|
||
linear-gradient(rgba(20, 26, 23, 0.03) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(20, 26, 23, 0.03) 1px, transparent 1px);
|
||
background-size: 28px 28px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.force-canvas svg {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.force-footer {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 12px;
|
||
padding: 12px 14px 14px;
|
||
border-top: 1px solid rgba(21, 32, 27, 0.08);
|
||
background: color-mix(in srgb, var(--card-strong) 96%, transparent);
|
||
}
|
||
|
||
.force-inspector {
|
||
display: grid;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.force-inspector-title {
|
||
color: var(--ink);
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.force-inspector-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(138px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.force-inspector-grid div {
|
||
padding: 9px 10px;
|
||
border-radius: 12px;
|
||
background: rgba(28, 124, 84, 0.06);
|
||
}
|
||
|
||
.force-inspector-grid strong {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.force-note {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.force-legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
align-items: flex-start;
|
||
min-width: 170px;
|
||
}
|
||
|
||
.force-legend-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.force-legend-line {
|
||
width: 24px;
|
||
height: 0;
|
||
border-top: 2px solid rgba(20, 26, 23, 0.22);
|
||
}
|
||
|
||
.force-legend-line.pair {
|
||
border-top-color: #2f72b8;
|
||
border-top-width: 3px;
|
||
}
|
||
|
||
.force-legend-line.anchor {
|
||
border-top-style: dashed;
|
||
border-top-color: rgba(28, 124, 84, 0.45);
|
||
}
|
||
|
||
.probability-figure {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 24px;
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.probability-legend {
|
||
display: grid;
|
||
grid-template-rows: auto auto 1fr auto;
|
||
justify-items: center;
|
||
gap: 6px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.probability-legend-title {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.02em;
|
||
text-align: center;
|
||
}
|
||
|
||
.probability-legend-bar {
|
||
width: 16px;
|
||
min-height: 240px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
background: var(--probability-gradient);
|
||
}
|
||
|
||
.probability-caption {
|
||
margin: 0 0 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.section-note {
|
||
margin: -2px 0 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
[data-help-visibility="hidden"] .section-note,
|
||
[data-help-visibility="hidden"] .raw-summary,
|
||
[data-help-visibility="hidden"] .probability-caption,
|
||
[data-help-visibility="hidden"] .viewer-hint,
|
||
[data-help-visibility="hidden"] .hero p {
|
||
display: none;
|
||
}
|
||
|
||
.heatmap-shell {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.heatmap-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 14px;
|
||
align-items: center;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.heatmap-legend {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.heatmap-legend-bar {
|
||
width: 108px;
|
||
height: 10px;
|
||
border-radius: 999px;
|
||
background: var(--heatmap-gradient);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
}
|
||
|
||
.color-settings {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.color-settings-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.color-preview {
|
||
height: 14px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(21, 32, 27, 0.12);
|
||
background: var(--heatmap-gradient);
|
||
}
|
||
|
||
.raw-summary {
|
||
margin-bottom: 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.chart-shell {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin: 6px 0 18px;
|
||
}
|
||
|
||
.chart-shell-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.chart-switch button {
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
.chart-switch .active {
|
||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.chart-focus {
|
||
margin: -2px 0 4px;
|
||
min-height: 18px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.table-collapse {
|
||
margin-top: 10px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
background: color-mix(in srgb, var(--card-strong) 90%, transparent);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.table-collapse summary {
|
||
margin: 0;
|
||
padding: 12px 14px;
|
||
color: var(--accent-strong);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.table-collapse-body {
|
||
padding: 0 14px 12px;
|
||
}
|
||
|
||
.table-collapse-note {
|
||
margin: 0 0 10px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.chart-canvas {
|
||
width: 100%;
|
||
min-height: 280px;
|
||
max-width: none;
|
||
background: var(--chart-surface);
|
||
}
|
||
|
||
.pie-shell {
|
||
display: grid;
|
||
grid-template-columns: 280px minmax(0, 1fr);
|
||
gap: 14px;
|
||
align-items: start;
|
||
}
|
||
|
||
.pie-shell canvas {
|
||
max-width: 280px;
|
||
min-height: 280px;
|
||
}
|
||
|
||
.pie-legend-list {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.pie-legend-item {
|
||
display: grid;
|
||
grid-template-columns: 14px minmax(0, 1fr) auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
font-size: 12px;
|
||
padding: 8px 10px;
|
||
border-radius: 12px;
|
||
border: 1px solid transparent;
|
||
background: color-mix(in srgb, var(--card-strong) 88%, transparent);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.pie-legend-item:hover,
|
||
.pie-legend-item.active {
|
||
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
|
||
background: color-mix(in srgb, var(--accent-soft) 70%, var(--card-strong));
|
||
}
|
||
|
||
.pie-dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.error-box {
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
background: rgba(143, 61, 22, 0.08);
|
||
color: var(--warn);
|
||
border: 1px solid rgba(143, 61, 22, 0.16);
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.history-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.history-item {
|
||
padding: 12px;
|
||
border-radius: 14px;
|
||
background: var(--card);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
}
|
||
|
||
.history-item p {
|
||
margin: 0 0 8px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.account-chip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
padding: 7px 8px 7px 12px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--card);
|
||
}
|
||
|
||
.account-chip strong,
|
||
.account-chip span {
|
||
display: block;
|
||
max-width: 220px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.account-chip span {
|
||
margin-top: 2px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.account-chip a {
|
||
padding: 7px 10px;
|
||
border-left: 1px solid var(--line);
|
||
color: var(--accent-strong);
|
||
font-size: 12px;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.usage-panel {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: var(--card);
|
||
}
|
||
|
||
.usage-metric {
|
||
min-width: 0;
|
||
padding: 10px 12px;
|
||
border-right: 1px solid var(--line);
|
||
}
|
||
|
||
.usage-metric:last-child {
|
||
border-right: 0;
|
||
}
|
||
|
||
.usage-metric strong,
|
||
.usage-metric span {
|
||
display: block;
|
||
}
|
||
|
||
.usage-metric strong {
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.usage-metric span {
|
||
margin-top: 4px;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.history-filters {
|
||
display: grid;
|
||
grid-template-columns: minmax(130px, 1.5fr) repeat(4, minmax(90px, 1fr));
|
||
gap: 8px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.history-filters input,
|
||
.history-filters select {
|
||
padding: 8px 9px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.history-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 5px 12px;
|
||
margin-bottom: 9px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.history-meta strong {
|
||
color: var(--ink);
|
||
}
|
||
|
||
.history-pagination,
|
||
.share-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.share-heading {
|
||
margin: 18px 0 10px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.share-heading h3 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.share-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.share-row select {
|
||
width: auto;
|
||
min-width: 110px;
|
||
padding: 7px 8px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
body.share-mode #historySection,
|
||
body.share-mode .control-panel > .panel-inner > .actions,
|
||
body.share-mode #usagePanel,
|
||
body.share-mode #accountChip {
|
||
display: none !important;
|
||
}
|
||
|
||
body.share-mode .control-panel input:disabled,
|
||
body.share-mode .control-panel select:disabled,
|
||
body.share-mode .control-panel textarea:disabled,
|
||
body.share-mode .control-panel button:disabled {
|
||
cursor: default;
|
||
opacity: 0.72;
|
||
}
|
||
|
||
.compact-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.compact-actions button {
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
.toggle-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin: 10px 0 12px;
|
||
}
|
||
|
||
.panel-toggle.active {
|
||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.design-panel {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.design-panel.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.subsection {
|
||
padding: 14px;
|
||
border-radius: 18px;
|
||
border: 1px solid rgba(21, 32, 27, 0.09);
|
||
background: color-mix(in srgb, var(--card-strong) 86%, transparent);
|
||
}
|
||
|
||
.subsection summary {
|
||
margin: 0;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.subsection-body {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.minor-note {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.constraint-field-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.nested-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.nested-card {
|
||
padding: 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
background: color-mix(in srgb, var(--panel-strong) 88%, transparent);
|
||
}
|
||
|
||
.nested-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.viewer {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 22px;
|
||
background: rgba(9, 14, 11, 0.72);
|
||
backdrop-filter: blur(8px);
|
||
z-index: 40;
|
||
}
|
||
|
||
.viewer.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.viewer-panel {
|
||
width: min(1440px, 100%);
|
||
max-height: calc(100vh - 44px);
|
||
display: grid;
|
||
grid-template-rows: auto minmax(0, 1fr);
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border-radius: 24px;
|
||
background: color-mix(in srgb, var(--panel-strong) 96%, transparent);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.viewer-top {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.viewer-title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.viewer-hint {
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.viewer-body {
|
||
overflow: auto;
|
||
border-radius: 18px;
|
||
background: var(--card-strong);
|
||
border: 1px solid rgba(21, 32, 27, 0.08);
|
||
padding: 16px;
|
||
}
|
||
|
||
.viewer-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.viewer-stage {
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.viewer-stage svg {
|
||
display: block;
|
||
width: auto;
|
||
height: auto;
|
||
max-width: none;
|
||
margin: 0 auto;
|
||
transform-origin: center center;
|
||
}
|
||
|
||
@media (min-width: 1025px) {
|
||
.control-panel,
|
||
.results-panel {
|
||
max-height: calc(100vh - 64px);
|
||
overflow: auto;
|
||
scrollbar-gutter: stable;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.control-panel {
|
||
position: static;
|
||
}
|
||
|
||
.result-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.pie-shell {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.strand-meta-grid {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
|
||
.history-filters {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.history-filters,
|
||
.share-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.usage-panel {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="shell">
|
||
<section class="hero">
|
||
<div class="hero-top">
|
||
<div class="pill" id="statusBadge"></div>
|
||
<div class="account-chip hidden" id="accountChip">
|
||
<div><strong id="accountName"></strong><span id="accountEmail"></span></div>
|
||
<a href="/auth/logout" data-i18n="account_logout"></a>
|
||
</div>
|
||
<div class="toolbar">
|
||
<label>
|
||
<span data-i18n="language_label"></span>
|
||
<select id="language">
|
||
<option value="zh">中文</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="theme_label"></span>
|
||
<select id="themeSelect">
|
||
<option value="linen" data-i18n="theme_linen"></option>
|
||
<option value="graphite" data-i18n="theme_graphite"></option>
|
||
<option value="tide" data-i18n="theme_tide"></option>
|
||
<option value="ember" data-i18n="theme_ember"></option>
|
||
<option value="sage" data-i18n="theme_sage"></option>
|
||
<option value="harbor" data-i18n="theme_harbor"></option>
|
||
<option value="studio" data-i18n="theme_studio"></option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="layout_label"></span>
|
||
<select id="layoutSelect">
|
||
<option value="balanced" data-i18n="layout_balanced"></option>
|
||
<option value="editorial" data-i18n="layout_editorial"></option>
|
||
<option value="dashboard" data-i18n="layout_dashboard"></option>
|
||
<option value="stacked" data-i18n="layout_stacked"></option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="help_label"></span>
|
||
<select id="helpToggle">
|
||
<option value="hidden" data-i18n="help_hidden"></option>
|
||
<option value="shown" data-i18n="help_shown"></option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<h1 data-i18n="hero_title"></h1>
|
||
<p data-i18n="hero_desc"></p>
|
||
<div class="queue-panel">
|
||
<div class="metric"><strong data-i18n="queue_running"></strong><span class="metric-value" id="queueRunning">-</span></div>
|
||
<div class="metric"><strong data-i18n="queue_waiting"></strong><span class="metric-value" id="queueQueued">-</span></div>
|
||
<div class="metric"><strong data-i18n="queue_concurrency"></strong><span class="metric-value" id="queueConcurrency">-</span></div>
|
||
<div class="metric"><strong data-i18n="queue_threads"></strong><span class="metric-value" id="queueThreads">-</span></div>
|
||
</div>
|
||
<div class="usage-panel hidden" id="usagePanel">
|
||
<div class="usage-metric"><strong data-i18n="usage_total"></strong><span id="usageTotal">-</span></div>
|
||
<div class="usage-metric"><strong data-i18n="usage_success"></strong><span id="usageSuccess">-</span></div>
|
||
<div class="usage-metric"><strong data-i18n="usage_error"></strong><span id="usageError">-</span></div>
|
||
<div class="usage-metric"><strong data-i18n="usage_active"></strong><span id="usageActive">-</span></div>
|
||
<div class="usage-metric"><strong data-i18n="usage_compute"></strong><span id="usageCompute">-</span></div>
|
||
<div class="usage-metric"><strong data-i18n="usage_storage"></strong><span id="usageStorage">-</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="layout">
|
||
<div class="panel control-panel">
|
||
<div class="panel-inner">
|
||
<div class="section" id="workflowModeSection">
|
||
<h2 data-i18n="mode_title"></h2>
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="workflow_label"></span>
|
||
<select id="workflow">
|
||
<option value="analysis" data-i18n="workflow_analysis"></option>
|
||
<option value="design" data-i18n="workflow_design"></option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="mode_label"></span>
|
||
<select id="mode">
|
||
<option value="tube" data-i18n="mode_tube"></option>
|
||
<option value="complex" data-i18n="mode_complex"></option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="modelSection">
|
||
<h2 data-i18n="model_title"></h2>
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="material_label"></span>
|
||
<select id="material">
|
||
<option value="rna">rna</option>
|
||
<option value="dna04">dna04</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="ensemble_label"></span>
|
||
<select id="ensemble">
|
||
<option value="stacking">stacking</option>
|
||
<option value="nostacking">nostacking</option>
|
||
<option value="some-nupack3">some-nupack3</option>
|
||
<option value="all-nupack3">all-nupack3</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="celsius_label"></span>
|
||
<input id="celsius" type="number" value="37" step="0.1" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="sodium_label"></span>
|
||
<input id="sodium" type="number" value="1.0" step="0.01" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="magnesium_label"></span>
|
||
<input id="magnesium" type="number" value="0.0" step="0.01" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="strandSection">
|
||
<h2 data-i18n="strand_title"></h2>
|
||
<div class="strand-list" id="strandList"></div>
|
||
<div class="strand-actions">
|
||
<button class="secondary" id="addStrandBtn" type="button" data-i18n="add_strand"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="computeSection">
|
||
<h2 data-i18n="compute_title"></h2>
|
||
<div class="check-grid">
|
||
<label class="check"><input type="checkbox" value="pfunc" checked />pfunc</label>
|
||
<label class="check"><input type="checkbox" value="mfe" checked />mfe</label>
|
||
<label class="check"><input type="checkbox" value="pairs" checked />pairs</label>
|
||
<label class="check"><input type="checkbox" value="sample" />sample</label>
|
||
<label class="check"><input type="checkbox" value="subopt" />subopt</label>
|
||
<label class="check"><input type="checkbox" value="ensemble_size" />ensemble_size</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="optionsSection">
|
||
<h2 data-i18n="options_title"></h2>
|
||
<div class="grid cols-2">
|
||
<label>
|
||
num_sample
|
||
<input id="numSample" type="number" value="20" step="1" />
|
||
</label>
|
||
<label>
|
||
energy_gap
|
||
<input id="energyGap" type="number" value="1.0" step="0.1" />
|
||
</label>
|
||
<label>
|
||
sparsity_fraction
|
||
<input id="sparsityFraction" type="number" value="1.0" step="0.1" />
|
||
</label>
|
||
<label>
|
||
sparsity_threshold
|
||
<input id="sparsityThreshold" type="number" value="0.0" step="0.01" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="result_limit_label"></span>
|
||
<input id="resultLimit" type="number" value="25" min="1" step="1" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="pairs_preview_size_label"></span>
|
||
<input id="pairsPreviewSize" type="number" value="24" min="8" max="128" step="1" />
|
||
</label>
|
||
</div>
|
||
<p class="section-note" data-i18n="result_limit_hint"></p>
|
||
</div>
|
||
|
||
<div class="section" id="colorSettingsSection">
|
||
<h2 data-i18n="color_settings_title"></h2>
|
||
<div class="color-settings">
|
||
<div class="color-settings-row">
|
||
<label>
|
||
<span data-i18n="color_low_label"></span>
|
||
<input id="colorLow" type="color" value="#f7f4e7" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="color_high_label"></span>
|
||
<input id="colorHigh" type="color" value="#579b3b" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="graph_font_size_label"></span>
|
||
<select id="graphFontSize">
|
||
<option value="small" data-i18n="graph_font_small"></option>
|
||
<option value="normal" data-i18n="graph_font_normal"></option>
|
||
<option value="large" data-i18n="graph_font_large"></option>
|
||
<option value="xlarge" data-i18n="graph_font_xlarge"></option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="color-preview" id="colorPreview"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="resetColorBtn" type="button" data-i18n="color_reset_btn"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section" id="tubeSection">
|
||
<h2 data-i18n="tube_title"></h2>
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="tube_name"></span>
|
||
<input id="tubeName" type="text" value="tube1" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="tube_size"></span>
|
||
<input id="maxSize" type="number" value="2" min="1" step="1" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section hidden" id="complexSection">
|
||
<h2 data-i18n="complex_title"></h2>
|
||
<label>
|
||
<span data-i18n="complex_help"></span>
|
||
<textarea id="complexesText" class="hidden">A
|
||
B
|
||
A+B</textarea>
|
||
</label>
|
||
<div class="complex-editor" id="complexList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addComplexBtn" type="button" data-i18n="add_complex"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section hidden" id="designSection">
|
||
<h2 data-i18n="design_title"></h2>
|
||
<p class="section-note" data-i18n="design_help"></p>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="designGuideBtn" type="button" data-i18n="design_guide_btn"></button>
|
||
</div>
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="design_trials_label"></span>
|
||
<input id="designTrials" type="number" value="1" min="1" max="8" step="1" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_stop_condition"></span>
|
||
<input id="designStopCondition" type="number" value="0.02" min="0.001" max="0.99" step="0.01" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_max_time_hours"></span>
|
||
<input id="designMaxTimeHours" type="number" value="0" min="0" step="0.5" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_seed_label"></span>
|
||
<input id="designSeed" type="number" value="0" step="1" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_wobble_label"></span>
|
||
<select id="designWobble">
|
||
<option value="false" data-i18n="design_wobble_prohibit"></option>
|
||
<option value="true" data-i18n="design_wobble_allow"></option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_fixed_target_policy"></span>
|
||
<select id="designFixedTargetPolicy">
|
||
<option value="exclude_from_optimization" data-i18n="design_fixed_target_exclude"></option>
|
||
<option value="include" data-i18n="design_fixed_target_include"></option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="toggle-row" id="designTabToggle">
|
||
<button class="secondary panel-toggle active" type="button" data-design-tab="targets" data-i18n="design_tab_targets"></button>
|
||
<button class="secondary panel-toggle" type="button" data-design-tab="hard" data-i18n="design_tab_hard"></button>
|
||
<button class="secondary panel-toggle" type="button" data-design-tab="soft" data-i18n="design_tab_soft"></button>
|
||
<button class="secondary panel-toggle" type="button" data-design-tab="weights" data-i18n="design_tab_weights"></button>
|
||
</div>
|
||
<div class="design-panel" data-design-panel="targets" id="designTargetsPanel">
|
||
<details class="subsection" open id="designDomainSection">
|
||
<summary data-i18n="design_domains_title"></summary>
|
||
<div class="subsection-body">
|
||
<p class="section-note" data-i18n="design_domains_help"></p>
|
||
<div class="complex-editor" id="designDomainList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addDesignDomainBtn" type="button" data-i18n="add_design_domain"></button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details class="subsection" open>
|
||
<summary data-i18n="design_complexes_title"></summary>
|
||
<div class="subsection-body">
|
||
<div class="complex-editor" id="designComplexList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addDesignComplexBtn" type="button" data-i18n="add_design_complex"></button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details class="subsection" open id="designTubeEditor">
|
||
<summary data-i18n="design_tubes_title"></summary>
|
||
<div class="subsection-body">
|
||
<p class="minor-note" data-i18n="design_tubes_help"></p>
|
||
<div class="complex-editor" id="designTubeList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addDesignTubeBtn" type="button" data-i18n="add_design_tube"></button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="design-panel hidden" data-design-panel="hard" id="designHardPanel">
|
||
<details class="subsection" open>
|
||
<summary data-i18n="design_hard_constraints_title"></summary>
|
||
<div class="subsection-body">
|
||
<p class="minor-note" data-i18n="design_hard_constraints_help"></p>
|
||
<div class="complex-editor" id="hardConstraintList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addHardConstraintBtn" type="button" data-i18n="add_hard_constraint"></button>
|
||
</div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" type="button" data-hard-preset="match" data-i18n="preset_hard_match"></button>
|
||
<button class="secondary" type="button" data-hard-preset="complementarity" data-i18n="preset_hard_complementarity"></button>
|
||
<button class="secondary" type="button" data-hard-preset="similarity" data-i18n="preset_hard_similarity"></button>
|
||
<button class="secondary" type="button" data-hard-preset="window" data-i18n="preset_hard_window"></button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="design-panel hidden" data-design-panel="soft" id="designSoftPanel">
|
||
<details class="subsection" open>
|
||
<summary data-i18n="design_soft_constraints_title"></summary>
|
||
<div class="subsection-body">
|
||
<p class="minor-note" data-i18n="design_soft_constraints_help"></p>
|
||
<div class="complex-editor" id="softConstraintList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addSoftConstraintBtn" type="button" data-i18n="add_soft_constraint"></button>
|
||
</div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" type="button" data-soft-preset="pattern" data-i18n="preset_soft_pattern"></button>
|
||
<button class="secondary" type="button" data-soft-preset="similarity" data-i18n="preset_soft_similarity"></button>
|
||
<button class="secondary" type="button" data-soft-preset="ssm" data-i18n="preset_soft_ssm"></button>
|
||
<button class="secondary" type="button" data-soft-preset="energy_match" data-i18n="preset_soft_energy_match"></button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="design-panel hidden" data-design-panel="weights" id="designWeightsPanel">
|
||
<details class="subsection" open>
|
||
<summary data-i18n="design_defect_weights_title"></summary>
|
||
<div class="subsection-body">
|
||
<p class="minor-note" data-i18n="design_defect_weights_help"></p>
|
||
<div class="complex-editor" id="defectWeightList"></div>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="addDefectWeightBtn" type="button" data-i18n="add_defect_weight"></button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="primary" id="runBtn" type="button" data-i18n="run_btn"></button>
|
||
<button class="secondary" id="exampleBtn" type="button" data-i18n="example_btn"></button>
|
||
<button class="secondary" id="exportFailedExamplesBtn" type="button" data-i18n="export_failed_examples"></button>
|
||
</div>
|
||
|
||
<div class="section" id="historySection">
|
||
<h2 data-i18n="history_title"></h2>
|
||
<div class="compact-actions">
|
||
<button class="secondary" id="clearHistoryBtn" type="button" data-i18n="history_refresh"></button>
|
||
<button class="secondary" id="importHistoryBtn" type="button" data-i18n="history_import"></button>
|
||
<button class="secondary" id="exportHistoryBtn" type="button" data-i18n="history_export_all"></button>
|
||
<input id="historyImportInput" type="file" accept="application/json,.json" multiple hidden />
|
||
</div>
|
||
<div class="history-filters">
|
||
<input id="historySearch" type="search" data-i18n-placeholder="history_search" />
|
||
<select id="historyStatus"><option value="" data-i18n="history_all_status"></option><option value="queued">queued</option><option value="running">running</option><option value="success">success</option><option value="error">error</option><option value="canceled">canceled</option></select>
|
||
<select id="historyWorkflow"><option value="" data-i18n="history_all_workflows"></option><option value="analysis">analysis</option><option value="design">design</option></select>
|
||
<select id="historyMode"><option value="" data-i18n="history_all_modes"></option><option value="tube">tube</option><option value="complex">complex</option></select>
|
||
<select id="historyMaterial"><option value="" data-i18n="history_all_materials"></option><option value="rna">rna</option><option value="dna04">dna04</option></select>
|
||
</div>
|
||
<label>
|
||
<span data-i18n="share_duration"></span>
|
||
<select id="shareDuration"><option value="3600" data-i18n="share_1h"></option><option value="86400" data-i18n="share_1d"></option><option value="604800" data-i18n="share_7d"></option><option value="2592000" data-i18n="share_30d"></option><option value="" data-i18n="share_forever"></option></select>
|
||
</label>
|
||
<div class="history-list" id="historyList"></div>
|
||
<div class="history-pagination">
|
||
<button class="secondary" id="historyPrev" type="button" data-i18n="history_prev"></button>
|
||
<span id="historyPageInfo"></span>
|
||
<button class="secondary" id="historyNext" type="button" data-i18n="history_next"></button>
|
||
</div>
|
||
<div class="share-heading"><h3 data-i18n="share_management"></h3><span id="shareCount"></span></div>
|
||
<div class="history-list" id="shareList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel results-panel">
|
||
<div class="panel-inner">
|
||
<div class="results" id="results"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<div class="viewer hidden" id="viewer">
|
||
<div class="viewer-panel">
|
||
<div class="viewer-top">
|
||
<div>
|
||
<h3 class="viewer-title" id="viewerTitle"></h3>
|
||
<div class="viewer-hint" id="viewerHint"></div>
|
||
</div>
|
||
<div class="viewer-actions">
|
||
<button class="secondary" id="zoomOutBtn" type="button"></button>
|
||
<button class="secondary" id="zoomResetBtn" type="button"></button>
|
||
<button class="secondary" id="zoomInBtn" type="button"></button>
|
||
<button class="secondary" id="closeViewerBtn" type="button"></button>
|
||
</div>
|
||
</div>
|
||
<div class="viewer-body" id="viewerBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<template id="strandTemplate">
|
||
<div class="strand-card">
|
||
<div class="strand-meta-grid">
|
||
<label>
|
||
<span data-i18n="strand_name"></span>
|
||
<input data-field="name" type="text" placeholder="A" />
|
||
</label>
|
||
<label data-strand-concentration>
|
||
<span data-i18n="strand_concentration"></span>
|
||
<input data-field="concentration" type="number" value="1.0" step="0.01" />
|
||
</label>
|
||
<label data-strand-unit>
|
||
<span data-i18n="strand_unit"></span>
|
||
<select data-field="unit">
|
||
<option value="M">M</option>
|
||
<option value="mM">mM</option>
|
||
<option value="uM" selected>uM</option>
|
||
<option value="nM">nM</option>
|
||
<option value="pM">pM</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label class="sequence-field">
|
||
<span data-i18n="strand_sequence" data-strand-sequence-label></span>
|
||
<textarea data-field="sequence" placeholder="AGUCUAGGAU"></textarea>
|
||
</label>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="complexTemplate">
|
||
<div class="strand-card">
|
||
<label>
|
||
<span data-i18n="complex_row"></span>
|
||
<input data-field="complex" type="text" placeholder="A+B" />
|
||
</label>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="designDomainTemplate">
|
||
<div class="strand-card">
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="design_domain_name"></span>
|
||
<input data-field="name" type="text" placeholder="a" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_domain_constraint"></span>
|
||
<input data-field="sequence" type="text" placeholder="N10" />
|
||
</label>
|
||
</div>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="designComplexTemplate">
|
||
<div class="strand-card">
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="design_complex_name"></span>
|
||
<input data-field="name" type="text" placeholder="AB_target" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_target_strands"></span>
|
||
<input data-field="strands" type="text" placeholder="A+B" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_target_structure"></span>
|
||
<input data-field="structure" type="text" placeholder="(10+)10" />
|
||
</label>
|
||
</div>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="designTubeTemplate">
|
||
<div class="strand-card">
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="design_tube_name"></span>
|
||
<input data-field="name" type="text" placeholder="Tube 1" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_off_target_size"></span>
|
||
<input data-field="max_size" type="number" value="2" min="1" max="8" step="1" />
|
||
</label>
|
||
</div>
|
||
<div class="nested-list" data-on-target-list style="margin-top: 10px"></div>
|
||
<div class="nested-actions">
|
||
<button class="secondary" data-add-on-target type="button" data-i18n="add_design_on_target"></button>
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="designOnTargetTemplate">
|
||
<div class="nested-card">
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="design_on_target_complex"></span>
|
||
<input data-field="complex" type="text" placeholder="AB_target" list="designComplexNames" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_target_concentration"></span>
|
||
<input data-field="concentration" type="number" value="1.0" min="0.000001" step="0.01" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="design_target_unit"></span>
|
||
<select data-field="unit">
|
||
<option value="M">M</option>
|
||
<option value="mM">mM</option>
|
||
<option value="uM" selected>uM</option>
|
||
<option value="nM">nM</option>
|
||
<option value="pM">pM</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="hardConstraintTemplate">
|
||
<div class="strand-card">
|
||
<div class="constraint-field-grid">
|
||
<label>
|
||
<span data-i18n="constraint_type"></span>
|
||
<select data-field="type">
|
||
<option value="match">Match</option>
|
||
<option value="complementarity">Complementarity</option>
|
||
<option value="diversity">Diversity</option>
|
||
<option value="similarity">Similarity</option>
|
||
<option value="window">Window</option>
|
||
<option value="library">Library</option>
|
||
<option value="pattern">Pattern</option>
|
||
</select>
|
||
</label>
|
||
<label data-hard-field="left">
|
||
<span data-i18n="constraint_left"></span>
|
||
<input data-field="left" type="text" placeholder="a 或 A" list="designEntityNames" />
|
||
</label>
|
||
<label data-hard-field="right">
|
||
<span data-i18n="constraint_right"></span>
|
||
<input data-field="right" type="text" placeholder="~a 或 B" list="designEntityNames" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="scope">
|
||
<span data-i18n="constraint_scope"></span>
|
||
<input data-field="scope" type="text" placeholder="A 或 a" list="designEntityNames" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="reference">
|
||
<span data-i18n="constraint_reference"></span>
|
||
<input data-field="reference" type="text" placeholder="S20" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="sources">
|
||
<span data-i18n="constraint_sources"></span>
|
||
<textarea data-field="sources" placeholder="每行一个 source sequence"></textarea>
|
||
</label>
|
||
<label class="hidden" data-hard-field="catalog">
|
||
<span data-i18n="constraint_catalog"></span>
|
||
<textarea data-field="catalog" placeholder="每行一个 library;同行用逗号分隔可选序列"></textarea>
|
||
</label>
|
||
<label class="hidden" data-hard-field="patterns">
|
||
<span data-i18n="constraint_patterns"></span>
|
||
<textarea data-field="patterns" placeholder="A4, U4"></textarea>
|
||
</label>
|
||
<label class="hidden" data-hard-field="min_fraction">
|
||
<span data-i18n="constraint_min_fraction"></span>
|
||
<input data-field="min_fraction" type="number" value="0.45" min="0" max="1" step="0.01" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="max_fraction">
|
||
<span data-i18n="constraint_max_fraction"></span>
|
||
<input data-field="max_fraction" type="number" value="0.55" min="0" max="1" step="0.01" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="word">
|
||
<span data-i18n="constraint_word"></span>
|
||
<input data-field="word" type="number" value="4" min="1" step="1" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="types">
|
||
<span data-i18n="constraint_types"></span>
|
||
<input data-field="types" type="number" value="2" min="1" max="4" step="1" />
|
||
</label>
|
||
<label class="hidden" data-hard-field="wobble_mutations">
|
||
<span data-i18n="constraint_wobble"></span>
|
||
<select data-field="wobble_mutations">
|
||
<option value="false" data-i18n="design_wobble_prohibit"></option>
|
||
<option value="true" data-i18n="design_wobble_allow"></option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="softConstraintTemplate">
|
||
<div class="strand-card">
|
||
<div class="constraint-field-grid">
|
||
<label>
|
||
<span data-i18n="constraint_type"></span>
|
||
<select data-field="type">
|
||
<option value="pattern">Pattern</option>
|
||
<option value="similarity">Similarity</option>
|
||
<option value="ssm">SSM</option>
|
||
<option value="energy_match">EnergyMatch</option>
|
||
</select>
|
||
</label>
|
||
<label data-soft-field="scope">
|
||
<span data-i18n="constraint_scope"></span>
|
||
<input data-field="scope" type="text" placeholder="留空表示 global;也可用 A 或 a b" />
|
||
</label>
|
||
<label class="hidden" data-soft-field="reference">
|
||
<span data-i18n="constraint_reference"></span>
|
||
<input data-field="reference" type="text" placeholder="S20" />
|
||
</label>
|
||
<label class="hidden" data-soft-field="patterns">
|
||
<span data-i18n="constraint_patterns"></span>
|
||
<textarea data-field="patterns" placeholder="A4, U4"></textarea>
|
||
</label>
|
||
<label class="hidden" data-soft-field="min_fraction">
|
||
<span data-i18n="constraint_min_fraction"></span>
|
||
<input data-field="min_fraction" type="number" value="0.45" min="0" max="1" step="0.01" />
|
||
</label>
|
||
<label class="hidden" data-soft-field="max_fraction">
|
||
<span data-i18n="constraint_max_fraction"></span>
|
||
<input data-field="max_fraction" type="number" value="0.55" min="0" max="1" step="0.01" />
|
||
</label>
|
||
<label class="hidden" data-soft-field="word">
|
||
<span data-i18n="constraint_word"></span>
|
||
<input data-field="word" type="number" value="4" min="1" step="1" />
|
||
</label>
|
||
<label class="hidden" data-soft-field="energy_ref">
|
||
<span data-i18n="constraint_energy_ref"></span>
|
||
<input data-field="energy_ref" type="number" step="0.1" placeholder="-17" />
|
||
</label>
|
||
<label data-soft-field="weight">
|
||
<span data-i18n="constraint_weight"></span>
|
||
<input data-field="weight" type="number" value="1.0" min="0" step="0.1" />
|
||
</label>
|
||
</div>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template id="defectWeightTemplate">
|
||
<div class="strand-card">
|
||
<div class="grid cols-2">
|
||
<label>
|
||
<span data-i18n="design_weight_kind"></span>
|
||
<select data-field="kind">
|
||
<option value="global" data-i18n="design_weight_global"></option>
|
||
<option value="domain" data-i18n="design_weight_domain"></option>
|
||
<option value="strand" data-i18n="design_weight_strand"></option>
|
||
<option value="complex" data-i18n="design_weight_complex"></option>
|
||
<option value="tube" data-i18n="design_weight_tube"></option>
|
||
</select>
|
||
</label>
|
||
<label data-weight-name>
|
||
<span data-i18n="design_weight_name"></span>
|
||
<input data-field="name" type="text" placeholder="a / A / AB_target / Tube 1" list="designEntityNames" />
|
||
</label>
|
||
<label>
|
||
<span data-i18n="constraint_weight"></span>
|
||
<input data-field="weight" type="number" value="1.0" min="0" step="0.1" />
|
||
</label>
|
||
</div>
|
||
<div class="strand-actions" style="margin-top: 10px">
|
||
<button class="secondary" data-remove type="button" data-i18n="remove_btn"></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<datalist id="designComplexNames"></datalist>
|
||
<datalist id="designEntityNames"></datalist>
|
||
|
||
<script>
|
||
const I18N = {
|
||
zh: {
|
||
language_label: "语言",
|
||
theme_label: "主题",
|
||
help_label: "说明",
|
||
help_hidden: "隐藏",
|
||
help_shown: "显示",
|
||
theme_linen: "米白",
|
||
theme_graphite: "石墨",
|
||
theme_tide: "海潮",
|
||
theme_ember: "陶土",
|
||
theme_sage: "苔原",
|
||
theme_harbor: "港湾",
|
||
theme_studio: "夜棚",
|
||
layout_label: "布局",
|
||
layout_balanced: "均衡",
|
||
layout_editorial: "长卷",
|
||
layout_dashboard: "仪表板",
|
||
layout_stacked: "上下堆叠",
|
||
hero_title: "NUPACK",
|
||
hero_desc: "统一入口覆盖分析与设计任务,强调结构结果、浓度分布与可追溯导出。",
|
||
queue_running: "运行中任务",
|
||
queue_waiting: "排队任务",
|
||
queue_concurrency: "并发上限",
|
||
queue_threads: "单任务线程上限",
|
||
status_idle: "等待分析任务",
|
||
status_running: "正在提交任务...",
|
||
status_queued: "任务已进入队列...",
|
||
status_polling: "任务正在执行",
|
||
status_done: "分析完成",
|
||
status_error: "分析失败",
|
||
current_job: "当前任务",
|
||
status_waiting_job: "等待任务完成",
|
||
mode_title: "任务模式",
|
||
workflow_label: "工作流",
|
||
workflow_analysis: "Analysis",
|
||
workflow_design: "Design",
|
||
mode_label: "模式",
|
||
mode_tube: "Tube 分析",
|
||
mode_complex: "Complex 分析",
|
||
mode_tube_design: "Tube 设计",
|
||
mode_complex_design: "Complex 设计",
|
||
model_title: "模型参数",
|
||
material_label: "材料体系",
|
||
ensemble_label: "Ensemble",
|
||
celsius_label: "温度 (C)",
|
||
sodium_label: "钠离子浓度 (M)",
|
||
magnesium_label: "镁离子浓度 (M)",
|
||
strand_title: "链输入",
|
||
add_strand: "新增链",
|
||
strand_name: "名称",
|
||
strand_sequence: "序列",
|
||
strand_concentration: "浓度",
|
||
strand_unit: "单位",
|
||
remove_btn: "删除",
|
||
compute_title: "计算项目",
|
||
options_title: "高级选项",
|
||
result_limit_label: "最大输出复合物数 / pairs 计算数",
|
||
pairs_preview_size_label: "配对热图预览尺寸",
|
||
color_settings_title: "颜色设置",
|
||
color_low_label: "低概率色",
|
||
color_high_label: "高概率色",
|
||
color_reset_btn: "重置颜色",
|
||
tube_title: "Tube 配置",
|
||
tube_name: "Tube 名称",
|
||
tube_size: "复合物最大尺寸",
|
||
complex_title: "复合物列表",
|
||
complex_help: "每行一个复合物,用 `+` 连接链名。",
|
||
add_complex: "新增复合物",
|
||
complex_row: "复合物定义",
|
||
design_title: "Design 目标",
|
||
design_help: "Design 按 NUPACK 的 `Domain -> TargetStrand -> TargetComplex/TargetTube` 关系组织。可先定义 domain 约束,再在链输入中用空格分隔的 domain composition 组装链;也可让某条链继续直接使用整链 IUPAC 约束。",
|
||
design_guide_btn: "打开 Design 教学",
|
||
design_stop_condition: "停止条件 f_stop",
|
||
design_max_time_hours: "最大设计时间 (小时)",
|
||
design_seed_label: "随机种子",
|
||
design_wobble_label: "Wobble mutations",
|
||
design_wobble_allow: "允许",
|
||
design_wobble_prohibit: "禁止",
|
||
design_fixed_target_policy: "固定目标策略",
|
||
design_fixed_target_exclude: "剥离出优化",
|
||
design_fixed_target_include: "参与优化",
|
||
design_tab_targets: "Target Tubes",
|
||
design_tab_hard: "Hard Constraints",
|
||
design_tab_soft: "Soft Constraints",
|
||
design_tab_weights: "Defect Weights",
|
||
design_domains_title: "Design Domains",
|
||
design_domains_help: "Domain 使用 IUPAC 退化碱基约束,例如 `a = N10`。链输入区此时填写 domain composition,如 `a b ~c`;`~a` 表示反向互补。",
|
||
design_complexes_title: "Target complexes",
|
||
design_complex_name: "Target complex",
|
||
design_tubes_title: "Target tubes",
|
||
design_tubes_help: "Tube 设计时,每个 target tube 指定 on-target complexes 及其目标浓度;off-target 通过 max size 自动补全。",
|
||
design_tube_name: "Target tube",
|
||
design_on_target_complex: "On-target complex",
|
||
add_design_on_target: "新增 On-target",
|
||
add_design_complex: "新增 Complex",
|
||
add_design_tube: "新增 Tube",
|
||
design_hard_constraints_title: "Hard constraints",
|
||
design_hard_constraints_help: "先接入 NUPACK 文档中语义明确、且当前 Python 库可直接构造的类型:Match、Complementarity、Diversity、Similarity、Window、Library、Pattern。",
|
||
design_soft_constraints_title: "Soft constraints",
|
||
design_soft_constraints_help: "当前接入 Pattern、Similarity、SSM、EnergyMatch。软约束通过 weight 进入加权目标函数,不会替代 ensemble defect。",
|
||
design_defect_weights_title: "Defect weights",
|
||
design_defect_weights_help: "按 tube / complex / strand / domain 或全局重加权 defect,改变优化优先级;权重越大,设计器越偏向压低对应实体的缺陷。",
|
||
add_hard_constraint: "新增 Hard constraint",
|
||
add_soft_constraint: "新增 Soft constraint",
|
||
add_defect_weight: "新增 Weight",
|
||
preset_hard_match: "模板: Match",
|
||
preset_hard_complementarity: "模板: Complementarity",
|
||
preset_hard_similarity: "模板: Similarity",
|
||
preset_hard_window: "模板: Window",
|
||
preset_soft_pattern: "模板: Pattern",
|
||
preset_soft_similarity: "模板: Similarity",
|
||
preset_soft_ssm: "模板: SSM",
|
||
preset_soft_energy_match: "模板: EnergyMatch",
|
||
constraint_type: "类型",
|
||
constraint_left: "左侧 domains / strand",
|
||
constraint_right: "右侧 domains / strand",
|
||
constraint_scope: "作用域 / 目标",
|
||
constraint_reference: "参考序列",
|
||
constraint_sources: "来源序列",
|
||
constraint_catalog: "Library catalog",
|
||
constraint_patterns: "Pattern 列表",
|
||
constraint_min_fraction: "下限",
|
||
constraint_max_fraction: "上限",
|
||
constraint_word: "窗口长度",
|
||
constraint_types: "最少碱基类型数",
|
||
constraint_wobble: "允许 wobble",
|
||
constraint_energy_ref: "参考自由能 (kcal/mol)",
|
||
constraint_weight: "权重",
|
||
design_weight_kind: "加权层级",
|
||
design_weight_name: "实体名称",
|
||
design_weight_global: "全局",
|
||
design_weight_domain: "Domain",
|
||
design_weight_strand: "Strand",
|
||
design_weight_complex: "Complex",
|
||
design_weight_tube: "Tube",
|
||
add_design_domain: "新增 Domain",
|
||
design_domain_name: "Domain 名称",
|
||
design_domain_constraint: "Domain 约束",
|
||
design_trials_label: "设计试验数",
|
||
design_off_target_size: "Off-target 最大尺寸",
|
||
add_design_target: "新增设计目标",
|
||
design_target_name: "目标名称",
|
||
design_target_strands: "链组合",
|
||
design_target_structure: "目标结构",
|
||
design_target_concentration: "目标浓度",
|
||
design_target_unit: "浓度单位",
|
||
strand_sequence_design: "Domain composition / 整链约束",
|
||
strand_sequence_design_placeholder: "a b ~c 或 N10A5",
|
||
run_btn: "开始分析",
|
||
run_design_btn: "开始设计",
|
||
run_waiting_btn: "终止任务",
|
||
status_canceling: "正在终止任务...",
|
||
status_canceled: "任务已终止",
|
||
cancel_job_failed: "终止任务失败",
|
||
submission_locked: "已有任务在等待结果返回,请终止或等待。",
|
||
example_btn: "载入示例",
|
||
export_failed_examples: "导出错误 JSON",
|
||
no_failed_examples: "暂无可导出的错误样本。",
|
||
account_logout: "退出",
|
||
usage_total: "任务总数",
|
||
usage_success: "成功",
|
||
usage_error: "失败",
|
||
usage_active: "进行中",
|
||
usage_compute: "累计计算",
|
||
usage_storage: "云端存储",
|
||
history_title: "账号工作与云端记录",
|
||
history_refresh: "刷新",
|
||
history_search: "搜索任务 ID / 配置",
|
||
history_all_status: "全部状态",
|
||
history_all_workflows: "全部工作流",
|
||
history_all_modes: "全部模式",
|
||
history_all_materials: "全部材料",
|
||
history_prev: "上一页",
|
||
history_next: "下一页",
|
||
share_duration: "新分享有效期",
|
||
share_1h: "1 小时",
|
||
share_1d: "1 天",
|
||
share_7d: "7 天",
|
||
share_30d: "30 天",
|
||
share_forever: "永久",
|
||
share_management: "分享管理",
|
||
share_copy: "复制链接",
|
||
share_disable: "关闭",
|
||
share_enable: "开启",
|
||
share_expired: "已过期",
|
||
share_permanent: "永久有效",
|
||
share_accesses: "访问",
|
||
history_page: "第 {page} / {pages} 页,共 {total} 条",
|
||
history_status: "状态",
|
||
history_config: "配置",
|
||
history_job_id: "任务",
|
||
history_elapsed: "耗时",
|
||
history_import_migrated: "本地历史已迁移到账号",
|
||
clear_history: "清空历史",
|
||
history_import: "导入历史",
|
||
history_export_all: "全部导出",
|
||
history_export_one: "导出",
|
||
history_share: "分享",
|
||
history_has_result: "已保存结果",
|
||
history_no_result: "仅保存输入",
|
||
history_share_success: "分享链接已生成",
|
||
history_share_copied: "分享链接已复制到剪切板",
|
||
history_share_failed: "分享失败",
|
||
history_shared_loaded: "已载入分享记录",
|
||
history_empty: "还没有历史任务。",
|
||
history_load: "载入",
|
||
history_delete: "删除",
|
||
history_mode_tube: "Tube",
|
||
history_mode_complex: "Complex",
|
||
count_complexes: "个复合物",
|
||
export_json: "导出结果 JSON",
|
||
export_input: "导出输入 JSON",
|
||
export_pairs_csv: "导出配对矩阵 CSV",
|
||
export_svg: "下载 SVG",
|
||
export_prob_svg: "下载概率 SVG",
|
||
fullscreen_svg: "全屏查看",
|
||
graph_font_size_label: "图中文字大小",
|
||
graph_font_small: "小",
|
||
graph_font_normal: "标准",
|
||
graph_font_large: "大",
|
||
graph_font_xlarge: "特大",
|
||
viewer_title: "结构图全屏查看",
|
||
viewer_hint: "可在弹层中滚动查看完整结构,按 Esc 关闭。",
|
||
close_viewer: "关闭",
|
||
zoom_in: "放大",
|
||
zoom_out: "缩小",
|
||
zoom_reset: "重置",
|
||
probability_structure: "碱基层面配对概率图",
|
||
probability_caption: "碱基字母颜色表示当前结构下每个碱基的边际配对倾向,不表示边概率。",
|
||
probability_legend_title: "节点值",
|
||
probability_legend_high: "1.0",
|
||
probability_legend_low: "0.0",
|
||
mfe_view: "MFE 结构",
|
||
pair_view: "碱基层面配对概率",
|
||
force_view: "力导向",
|
||
force_title: "力导向结构图",
|
||
force_hint: "初始坐标继承结构图,拖拽可微调,滚轮可缩放。",
|
||
force_fit: "适配视图",
|
||
force_reset: "重置布局",
|
||
force_relax: "重新松弛",
|
||
force_labels_on: "隐藏标签",
|
||
force_labels_off: "显示标签",
|
||
force_meta_residues: "碱基",
|
||
force_meta_pairs: "配对",
|
||
force_meta_strands: "链",
|
||
force_legend_backbone: "链骨架连接",
|
||
force_legend_pair: "结构配对边",
|
||
force_legend_anchor: "结构锚定力",
|
||
force_selection_title: "选中碱基",
|
||
force_selection_empty: "点击任意碱基可查看所属链、位点、配对对象和边际配对倾向。",
|
||
force_selection_index: "全局位点",
|
||
force_selection_strand: "所属链",
|
||
force_selection_local: "链内位点",
|
||
force_selection_partner: "配对对象",
|
||
force_selection_probability: "边际倾向",
|
||
force_unpaired: "未配对",
|
||
ready_title: "准备就绪",
|
||
ready_desc: "点击左侧按钮后,这里会显示模型参数、结果摘要、Tube 浓度结果、RNAplot 结构图以及原始 JSON。",
|
||
summary_strands: "链数量",
|
||
summary_temperature: "温度",
|
||
summary_elapsed: "耗时",
|
||
model_card: "模型参数",
|
||
summary_card: "结果摘要",
|
||
summary_mode: "模式",
|
||
summary_complex_count: "复合物数",
|
||
summary_displayed_count: "实际展示",
|
||
summary_compute: "计算项目",
|
||
summary_plot: "RNAplot",
|
||
plot_enabled: "已启用",
|
||
tube_card: "Tube 浓度结果",
|
||
tube_chart_title: "Tube 浓度柱状图",
|
||
tube_chart_caption: "默认展示柱状图。点击任一可见条目、标签或图例即可滚动到对应复合物卡片。",
|
||
tube_pie_title: "Tube 浓度分布饼图",
|
||
tube_pie_caption: "饼图适合快速看占比,点击分区或右侧图例即可定位复合物。",
|
||
tube_view_bar: "柱状图",
|
||
tube_view_pie: "饼图",
|
||
tube_focus_idle: "点击图中的条目、扇区或下方表格行可跳转到对应复合物。",
|
||
tube_focus_prefix: "当前定位",
|
||
tube_table_title: "浓度排序",
|
||
tube_table_hint: "默认折叠,点击展开后可按浓度快速定位具体复合物。",
|
||
others_label: "其他",
|
||
table_complex: "复合物",
|
||
table_concentration: "浓度 (M)",
|
||
table_target_concentration: "目标浓度 (M)",
|
||
complex_free_energy: "自由能",
|
||
complex_ensemble_size: "结构数",
|
||
complex_mfe_stack: "MFE 堆叠能",
|
||
complex_mfe: "MFE 结构",
|
||
complex_subopt: "Subopt 结构",
|
||
complex_sample: "采样结构",
|
||
complex_pairs: "配对概率热图与预览",
|
||
raw_json: "原始 JSON",
|
||
design_summary_card: "设计摘要",
|
||
design_domains_card: "设计后的 Domains",
|
||
design_strands_card: "设计后的链",
|
||
design_targets_card: "目标复合物",
|
||
design_defects_card: "设计缺陷",
|
||
design_ensemble_defect: "Ensemble defect",
|
||
design_trials_used: "试验数",
|
||
design_seed: "最佳种子",
|
||
design_fixed_excluded: "固定目标剥离",
|
||
design_constraint: "约束",
|
||
design_definition: "输入定义",
|
||
design_sequence: "设计序列",
|
||
design_target_structure_label: "目标结构",
|
||
design_target_members: "链组成",
|
||
design_target_actual: "实际浓度",
|
||
design_target_target: "目标浓度",
|
||
design_tube_defect: "Tube 缺陷",
|
||
design_complex_defect: "Complex 缺陷",
|
||
design_analyze_direct: "直接分析设计结果",
|
||
design_load_analysis: "载入为 Analysis 输入",
|
||
design_analysis_loaded: "已载入设计序列,可修改链浓度后分析",
|
||
design_analysis_unavailable: "没有可分析的设计结果",
|
||
details_json: "展开原始 JSON",
|
||
toggle_structure_data: "结构数据",
|
||
toggle_pairs_panel: "配对热图",
|
||
hidden_by_default: "默认隐藏,可按需展开以减少页面卡顿。",
|
||
result_limit_hint: "Tube 模式会先计算全量浓度排序,再只对输出的前 N 个复合物补算 pairs;调大 N 会增加耗时和结果体积。",
|
||
concentration_label: "浓度",
|
||
no_concentration: "未提供浓度",
|
||
heatmap_caption: "热图基于 NUPACK pairs 概率矩阵预览,当前展示左上角预览子矩阵。",
|
||
heatmap_legend: "概率强度",
|
||
raw_json_caption: "页面展示的是完整结果对象;如果只需要复现输入或做二次分析,优先使用导出按钮。",
|
||
validation_no_strands: "至少需要一条链。",
|
||
validation_name: "每条链都必须有名称。",
|
||
validation_sequence: "每条链都必须有序列。",
|
||
validation_concentration: "浓度必须是非负数字。",
|
||
validation_compute: "至少选择一个计算项目。",
|
||
validation_max_size: "复合物最大尺寸至少为 1。",
|
||
validation_complex_lines: "Complex 模式下请填写至少一行复合物定义。",
|
||
validation_pairs_preview: "配对热图预览尺寸需在 8 到 128 之间。",
|
||
validation_design_targets: "Design 模式下至少需要一个目标复合物。",
|
||
validation_design_tubes: "Tube 设计下至少需要一个 target tube,且每个 tube 需要至少一个 on-target 复合物。",
|
||
validation_design_structure: "每个设计目标都必须填写目标结构。",
|
||
validation_design_strands: "每个设计目标都必须填写链组合。",
|
||
validation_hard_constraint: "Hard constraint",
|
||
validation_soft_constraint: "Soft constraint",
|
||
validation_left_required: "必须填写左侧 scope。",
|
||
validation_right_required: "必须填写右侧 scope。",
|
||
validation_scope_required: "必须填写作用域 / 目标。",
|
||
validation_reference_required: "必须填写参考序列。",
|
||
validation_sources_required: "必须填写来源序列。",
|
||
validation_catalog_required: "必须填写 Library catalog。",
|
||
validation_patterns_required: "必须填写 Pattern 列表。",
|
||
request_failed: "请求失败",
|
||
request_timeout: "任务等待超时,请稍后重试或缩小输入规模。",
|
||
csv_no_pairs: "当前结果没有可导出的配对矩阵。",
|
||
history_import_success: "历史导入完成",
|
||
history_import_failed: "历史导入失败",
|
||
},
|
||
en: {
|
||
language_label: "Language",
|
||
theme_label: "Theme",
|
||
help_label: "Help",
|
||
help_hidden: "Hidden",
|
||
help_shown: "Shown",
|
||
theme_linen: "Linen",
|
||
theme_graphite: "Graphite",
|
||
theme_tide: "Tide",
|
||
theme_ember: "Ember",
|
||
theme_sage: "Sage",
|
||
theme_harbor: "Harbor",
|
||
theme_studio: "Studio",
|
||
layout_label: "Layout",
|
||
layout_balanced: "Balanced",
|
||
layout_editorial: "Editorial",
|
||
layout_dashboard: "Dashboard",
|
||
layout_stacked: "Stacked",
|
||
hero_title: "NUPACK",
|
||
hero_desc: "A unified entry point for analysis and design with stronger structure views, concentration summaries, and reproducible exports.",
|
||
queue_running: "Running Jobs",
|
||
queue_waiting: "Queued Jobs",
|
||
queue_concurrency: "Concurrency Limit",
|
||
queue_threads: "Per-job Thread Limit",
|
||
status_idle: "Waiting for an analysis job",
|
||
status_running: "Submitting job...",
|
||
status_queued: "Job queued...",
|
||
status_polling: "Job is running",
|
||
status_done: "Analysis complete",
|
||
status_error: "Analysis failed",
|
||
current_job: "Current Job",
|
||
status_waiting_job: "Waiting for completion",
|
||
mode_title: "Job Mode",
|
||
workflow_label: "Workflow",
|
||
workflow_analysis: "Analysis",
|
||
workflow_design: "Design",
|
||
mode_label: "Mode",
|
||
mode_tube: "Tube Analysis",
|
||
mode_complex: "Complex Analysis",
|
||
mode_tube_design: "Tube Design",
|
||
mode_complex_design: "Complex Design",
|
||
model_title: "Model Settings",
|
||
material_label: "Material",
|
||
ensemble_label: "Ensemble",
|
||
celsius_label: "Temperature (C)",
|
||
sodium_label: "Sodium (M)",
|
||
magnesium_label: "Magnesium (M)",
|
||
strand_title: "Strands",
|
||
add_strand: "Add Strand",
|
||
strand_name: "Name",
|
||
strand_sequence: "Sequence",
|
||
strand_concentration: "Concentration",
|
||
strand_unit: "Unit",
|
||
remove_btn: "Remove",
|
||
compute_title: "Compute",
|
||
options_title: "Advanced Options",
|
||
result_limit_label: "Max Output Complexes / Pairs Count",
|
||
pairs_preview_size_label: "Pairs Preview Size",
|
||
color_settings_title: "Color Settings",
|
||
color_low_label: "Low Probability Color",
|
||
color_high_label: "High Probability Color",
|
||
color_reset_btn: "Reset Colors",
|
||
tube_title: "Tube Settings",
|
||
tube_name: "Tube Name",
|
||
tube_size: "Max Complex Size",
|
||
complex_title: "Complexes",
|
||
complex_help: "One complex per line. Use `+` to join strand names.",
|
||
add_complex: "Add Complex",
|
||
complex_row: "Complex Definition",
|
||
design_title: "Design Targets",
|
||
design_help: "Design follows the NUPACK `Domain -> TargetStrand -> TargetComplex/TargetTube` hierarchy. You can define domains first and then compose strands from domain tokens, while still allowing any strand to stay as a whole-strand IUPAC constraint when needed.",
|
||
design_guide_btn: "Open Design Guide",
|
||
design_stop_condition: "Stop Condition f_stop",
|
||
design_max_time_hours: "Max Design Time (hours)",
|
||
design_seed_label: "Random Seed",
|
||
design_wobble_label: "Wobble Mutations",
|
||
design_wobble_allow: "Allow",
|
||
design_wobble_prohibit: "Prohibit",
|
||
design_fixed_target_policy: "Fixed Target Policy",
|
||
design_fixed_target_exclude: "Exclude from Optimization",
|
||
design_fixed_target_include: "Include in Optimization",
|
||
design_tab_targets: "Target Tubes",
|
||
design_tab_hard: "Hard Constraints",
|
||
design_tab_soft: "Soft Constraints",
|
||
design_tab_weights: "Defect Weights",
|
||
design_domains_title: "Design Domains",
|
||
design_domains_help: "Domains use IUPAC constraints such as `a = N10`. When domains are present, strand definitions should use a space-separated domain composition such as `a b ~c`; `~a` denotes the reverse complement.",
|
||
design_complexes_title: "Target Complexes",
|
||
design_complex_name: "Target Complex",
|
||
design_tubes_title: "Target Tubes",
|
||
design_tubes_help: "For tube design, each target tube specifies its on-target complexes and target concentrations; off-targets are generated from the max-size setting.",
|
||
design_tube_name: "Target Tube",
|
||
design_on_target_complex: "On-target Complex",
|
||
add_design_on_target: "Add On-target",
|
||
add_design_complex: "Add Complex",
|
||
add_design_tube: "Add Tube",
|
||
design_hard_constraints_title: "Hard Constraints",
|
||
design_hard_constraints_help: "This build wires in the hard-constraint types with clear NUPACK semantics and direct Python API support: Match, Complementarity, Diversity, Similarity, Window, Library, and Pattern.",
|
||
design_soft_constraints_title: "Soft Constraints",
|
||
design_soft_constraints_help: "This build wires in Pattern, Similarity, SSM, and EnergyMatch. Soft constraints contribute weighted penalties rather than replacing the ensemble-defect objective.",
|
||
design_defect_weights_title: "Defect Weights",
|
||
design_defect_weights_help: "Reweight defects globally or at the tube / complex / strand / domain level to shift optimization effort. Larger weights prioritize lower defects for the chosen entity.",
|
||
add_hard_constraint: "Add Hard Constraint",
|
||
add_soft_constraint: "Add Soft Constraint",
|
||
add_defect_weight: "Add Weight",
|
||
preset_hard_match: "Preset: Match",
|
||
preset_hard_complementarity: "Preset: Complementarity",
|
||
preset_hard_similarity: "Preset: Similarity",
|
||
preset_hard_window: "Preset: Window",
|
||
preset_soft_pattern: "Preset: Pattern",
|
||
preset_soft_similarity: "Preset: Similarity",
|
||
preset_soft_ssm: "Preset: SSM",
|
||
preset_soft_energy_match: "Preset: EnergyMatch",
|
||
constraint_type: "Type",
|
||
constraint_left: "Left Domains / Strand",
|
||
constraint_right: "Right Domains / Strand",
|
||
constraint_scope: "Scope / Target",
|
||
constraint_reference: "Reference Sequence",
|
||
constraint_sources: "Source Sequences",
|
||
constraint_catalog: "Library Catalog",
|
||
constraint_patterns: "Patterns",
|
||
constraint_min_fraction: "Lower Limit",
|
||
constraint_max_fraction: "Upper Limit",
|
||
constraint_word: "Word Length",
|
||
constraint_types: "Min Nucleotide Types",
|
||
constraint_wobble: "Allow Wobble",
|
||
constraint_energy_ref: "Reference Free Energy (kcal/mol)",
|
||
constraint_weight: "Weight",
|
||
design_weight_kind: "Weight Level",
|
||
design_weight_name: "Entity Name",
|
||
design_weight_global: "Global",
|
||
design_weight_domain: "Domain",
|
||
design_weight_strand: "Strand",
|
||
design_weight_complex: "Complex",
|
||
design_weight_tube: "Tube",
|
||
add_design_domain: "Add Domain",
|
||
design_domain_name: "Domain Name",
|
||
design_domain_constraint: "Domain Constraint",
|
||
design_trials_label: "Design Trials",
|
||
design_off_target_size: "Off-target Max Size",
|
||
add_design_target: "Add Design Target",
|
||
design_target_name: "Target Name",
|
||
design_target_strands: "Member Strands",
|
||
design_target_structure: "Target Structure",
|
||
design_target_concentration: "Target Concentration",
|
||
design_target_unit: "Concentration Unit",
|
||
strand_sequence_design: "Domain Composition / Whole-strand Constraint",
|
||
strand_sequence_design_placeholder: "a b ~c or N10A5",
|
||
run_btn: "Run Analysis",
|
||
run_design_btn: "Run Design",
|
||
run_waiting_btn: "Terminate Job",
|
||
status_canceling: "Terminating job...",
|
||
status_canceled: "Job canceled",
|
||
cancel_job_failed: "Cancel job failed",
|
||
submission_locked: "A job is already waiting for results. Cancel or wait.",
|
||
example_btn: "Load Example",
|
||
export_failed_examples: "Export Error JSON",
|
||
no_failed_examples: "No failed examples are available to export.",
|
||
account_logout: "Sign out",
|
||
usage_total: "Total jobs",
|
||
usage_success: "Succeeded",
|
||
usage_error: "Failed",
|
||
usage_active: "Active",
|
||
usage_compute: "Compute time",
|
||
usage_storage: "Cloud storage",
|
||
history_title: "Account Work and Cloud Records",
|
||
history_refresh: "Refresh",
|
||
history_search: "Search job ID or configuration",
|
||
history_all_status: "All statuses",
|
||
history_all_workflows: "All workflows",
|
||
history_all_modes: "All modes",
|
||
history_all_materials: "All materials",
|
||
history_prev: "Previous",
|
||
history_next: "Next",
|
||
share_duration: "New share duration",
|
||
share_1h: "1 hour",
|
||
share_1d: "1 day",
|
||
share_7d: "7 days",
|
||
share_30d: "30 days",
|
||
share_forever: "Permanent",
|
||
share_management: "Share management",
|
||
share_copy: "Copy link",
|
||
share_disable: "Disable",
|
||
share_enable: "Enable",
|
||
share_expired: "Expired",
|
||
share_permanent: "No expiry",
|
||
share_accesses: "Accesses",
|
||
history_page: "Page {page} of {pages}, {total} records",
|
||
history_status: "Status",
|
||
history_config: "Configuration",
|
||
history_job_id: "Job",
|
||
history_elapsed: "Elapsed",
|
||
history_import_migrated: "Local history migrated to your account",
|
||
clear_history: "Clear History",
|
||
history_import: "Import History",
|
||
history_export_all: "Export All",
|
||
history_export_one: "Export",
|
||
history_share: "Share",
|
||
history_has_result: "Result saved",
|
||
history_no_result: "Input only",
|
||
history_share_success: "Share link created",
|
||
history_share_copied: "Share link copied to clipboard",
|
||
history_share_failed: "Share failed",
|
||
history_shared_loaded: "Shared record loaded",
|
||
history_empty: "No saved jobs yet.",
|
||
history_load: "Load",
|
||
history_delete: "Delete",
|
||
history_mode_tube: "Tube",
|
||
history_mode_complex: "Complex",
|
||
count_complexes: "complexes",
|
||
export_json: "Export Result JSON",
|
||
export_input: "Export Input JSON",
|
||
export_pairs_csv: "Export Pairs CSV",
|
||
export_svg: "Download SVG",
|
||
export_prob_svg: "Download Probability SVG",
|
||
fullscreen_svg: "Fullscreen",
|
||
graph_font_size_label: "Figure Text Size",
|
||
graph_font_small: "Small",
|
||
graph_font_normal: "Normal",
|
||
graph_font_large: "Large",
|
||
graph_font_xlarge: "Extra Large",
|
||
viewer_title: "Fullscreen Structure View",
|
||
viewer_hint: "Scroll inside the viewer to inspect the full structure. Press Esc to close.",
|
||
close_viewer: "Close",
|
||
zoom_in: "Zoom In",
|
||
zoom_out: "Zoom Out",
|
||
zoom_reset: "Reset",
|
||
probability_structure: "Per-base Pairing Probability",
|
||
probability_caption: "Base letter color encodes the marginal pairing tendency of each base in the displayed structure, not an edge-probability map.",
|
||
probability_legend_title: "Node Value",
|
||
probability_legend_high: "1.0",
|
||
probability_legend_low: "0.0",
|
||
mfe_view: "MFE Structure",
|
||
pair_view: "Per-base Pairing Probability",
|
||
force_view: "Force Layout",
|
||
force_title: "Force-Directed Structure",
|
||
force_hint: "Initial positions inherit the structure plot. Drag to refine and use the mouse wheel to zoom.",
|
||
force_fit: "Fit View",
|
||
force_reset: "Reset Layout",
|
||
force_relax: "Relax Again",
|
||
force_labels_on: "Hide Labels",
|
||
force_labels_off: "Show Labels",
|
||
force_meta_residues: "Residues",
|
||
force_meta_pairs: "Pairs",
|
||
force_meta_strands: "Strands",
|
||
force_legend_backbone: "Backbone Links",
|
||
force_legend_pair: "Base-Pair Edges",
|
||
force_legend_anchor: "Structural Anchors",
|
||
force_selection_title: "Selected Residue",
|
||
force_selection_empty: "Click any residue to inspect its strand, position, base-pair partner, and marginal pairing tendency.",
|
||
force_selection_index: "Global Index",
|
||
force_selection_strand: "Strand",
|
||
force_selection_local: "Local Index",
|
||
force_selection_partner: "Partner",
|
||
force_selection_probability: "Marginal Tendency",
|
||
force_unpaired: "Unpaired",
|
||
ready_title: "Ready",
|
||
ready_desc: "Results will show model settings, a summary, tube concentrations, RNAplot diagrams, and raw JSON.",
|
||
summary_strands: "Strands",
|
||
summary_temperature: "Temperature",
|
||
summary_elapsed: "Elapsed",
|
||
model_card: "Model",
|
||
summary_card: "Summary",
|
||
summary_mode: "Mode",
|
||
summary_complex_count: "Complex Count",
|
||
summary_displayed_count: "Displayed",
|
||
summary_compute: "Compute",
|
||
summary_plot: "RNAplot",
|
||
plot_enabled: "Enabled",
|
||
tube_card: "Tube Concentrations",
|
||
tube_chart_title: "Tube Concentration Chart",
|
||
tube_chart_caption: "The bar view is the default. Click any visible row, label, or legend item to jump to the matching complex card.",
|
||
tube_pie_title: "Tube Distribution Pie",
|
||
tube_pie_caption: "The pie view gives a quick concentration share overview. Click a slice or legend item to jump to its complex.",
|
||
tube_view_bar: "Bar Chart",
|
||
tube_view_pie: "Pie Chart",
|
||
tube_focus_idle: "Click a chart element or concentration table row to jump to the matching complex.",
|
||
tube_focus_prefix: "Focused",
|
||
tube_table_title: "Concentration Ranking",
|
||
tube_table_hint: "Collapsed by default. Expand it when you want a precise concentration-sorted list and quick jump targets.",
|
||
others_label: "Others",
|
||
table_complex: "Complex",
|
||
table_concentration: "Concentration (M)",
|
||
table_target_concentration: "Target Concentration (M)",
|
||
complex_free_energy: "Free Energy",
|
||
complex_ensemble_size: "Ensemble Size",
|
||
complex_mfe_stack: "MFE Stack",
|
||
complex_mfe: "MFE Structure",
|
||
complex_subopt: "Subopt Structures",
|
||
complex_sample: "Sampled Structures",
|
||
complex_pairs: "Pair Probabilities Heatmap and Preview",
|
||
raw_json: "Raw JSON",
|
||
design_summary_card: "Design Summary",
|
||
design_domains_card: "Designed Domains",
|
||
design_strands_card: "Designed Strands",
|
||
design_targets_card: "Target Complexes",
|
||
design_defects_card: "Design Defects",
|
||
design_ensemble_defect: "Ensemble Defect",
|
||
design_trials_used: "Trials",
|
||
design_seed: "Best Seed",
|
||
design_fixed_excluded: "Fixed Targets Excluded",
|
||
design_constraint: "Constraint",
|
||
design_definition: "Input Definition",
|
||
design_sequence: "Designed Sequence",
|
||
design_target_structure_label: "Target Structure",
|
||
design_target_members: "Members",
|
||
design_target_actual: "Actual Concentration",
|
||
design_target_target: "Target Concentration",
|
||
design_tube_defect: "Tube Defect",
|
||
design_complex_defect: "Complex Defect",
|
||
design_analyze_direct: "Analyze Designed Result",
|
||
design_load_analysis: "Load as Analysis Input",
|
||
design_analysis_loaded: "Designed sequences loaded; adjust strand concentrations before analysis",
|
||
design_analysis_unavailable: "No designed result is available for analysis",
|
||
details_json: "Show raw JSON",
|
||
toggle_structure_data: "Structure Data",
|
||
toggle_pairs_panel: "Pair Heatmap",
|
||
hidden_by_default: "Hidden by default to keep large result pages responsive.",
|
||
result_limit_hint: "Tube mode computes full concentration ranking first, then computes pairs only for the top N returned complexes. Larger N increases runtime and response size.",
|
||
concentration_label: "Concentration",
|
||
no_concentration: "No concentration",
|
||
heatmap_caption: "The heatmap uses the NUPACK pairs probability preview and shows the top-left preview window.",
|
||
heatmap_legend: "Probability",
|
||
raw_json_caption: "The page renders the full result object; for reproducible inputs or downstream analysis, prefer the export buttons.",
|
||
validation_no_strands: "At least one strand is required.",
|
||
validation_name: "Every strand must have a name.",
|
||
validation_sequence: "Every strand must have a sequence.",
|
||
validation_concentration: "Concentration must be a non-negative number.",
|
||
validation_compute: "Select at least one compute option.",
|
||
validation_max_size: "Max complex size must be at least 1.",
|
||
validation_complex_lines: "Provide at least one complex definition in complex mode.",
|
||
validation_pairs_preview: "Pairs preview size must be between 8 and 128.",
|
||
validation_design_targets: "Provide at least one design target.",
|
||
validation_design_tubes: "Tube design requires at least one target tube, and every tube needs at least one on-target complex.",
|
||
validation_design_structure: "Every design target needs a structure.",
|
||
validation_design_strands: "Every design target needs a strand membership list.",
|
||
validation_hard_constraint: "Hard constraint",
|
||
validation_soft_constraint: "Soft constraint",
|
||
validation_left_required: "Left scope is required.",
|
||
validation_right_required: "Right scope is required.",
|
||
validation_scope_required: "Scope / target is required.",
|
||
validation_reference_required: "Reference sequence is required.",
|
||
validation_sources_required: "Source sequences are required.",
|
||
validation_catalog_required: "Library catalog is required.",
|
||
validation_patterns_required: "Patterns are required.",
|
||
request_failed: "Request failed",
|
||
request_timeout: "Timed out while waiting for the job. Retry later or reduce the input size.",
|
||
csv_no_pairs: "No pair-probability matrix is available in this result.",
|
||
history_import_success: "History import complete",
|
||
history_import_failed: "History import failed",
|
||
},
|
||
};
|
||
|
||
const strandList = document.getElementById("strandList");
|
||
const strandTemplate = document.getElementById("strandTemplate");
|
||
const complexTemplate = document.getElementById("complexTemplate");
|
||
const results = document.getElementById("results");
|
||
const statusBadge = document.getElementById("statusBadge");
|
||
const workflowSelect = document.getElementById("workflow");
|
||
const modeSelect = document.getElementById("mode");
|
||
const tubeSection = document.getElementById("tubeSection");
|
||
const complexSection = document.getElementById("complexSection");
|
||
const designSection = document.getElementById("designSection");
|
||
const designTubeEditor = document.getElementById("designTubeEditor");
|
||
const computeSection = document.getElementById("computeSection");
|
||
const optionsSection = document.getElementById("optionsSection");
|
||
const colorSettingsSection = document.getElementById("colorSettingsSection");
|
||
const languageSelect = document.getElementById("language");
|
||
const themeSelect = document.getElementById("themeSelect");
|
||
const layoutSelect = document.getElementById("layoutSelect");
|
||
const helpToggle = document.getElementById("helpToggle");
|
||
const complexList = document.getElementById("complexList");
|
||
const designComplexList = document.getElementById("designComplexList");
|
||
const designTubeList = document.getElementById("designTubeList");
|
||
const hardConstraintList = document.getElementById("hardConstraintList");
|
||
const softConstraintList = document.getElementById("softConstraintList");
|
||
const defectWeightList = document.getElementById("defectWeightList");
|
||
const historyList = document.getElementById("historyList");
|
||
const historyImportInput = document.getElementById("historyImportInput");
|
||
const accountChip = document.getElementById("accountChip");
|
||
const accountName = document.getElementById("accountName");
|
||
const accountEmail = document.getElementById("accountEmail");
|
||
const usagePanel = document.getElementById("usagePanel");
|
||
const historySearch = document.getElementById("historySearch");
|
||
const historyStatus = document.getElementById("historyStatus");
|
||
const historyWorkflow = document.getElementById("historyWorkflow");
|
||
const historyMode = document.getElementById("historyMode");
|
||
const historyMaterial = document.getElementById("historyMaterial");
|
||
const historyPrev = document.getElementById("historyPrev");
|
||
const historyNext = document.getElementById("historyNext");
|
||
const historyPageInfo = document.getElementById("historyPageInfo");
|
||
const shareDuration = document.getElementById("shareDuration");
|
||
const shareList = document.getElementById("shareList");
|
||
const shareCount = document.getElementById("shareCount");
|
||
const colorLowInput = document.getElementById("colorLow");
|
||
const colorHighInput = document.getElementById("colorHigh");
|
||
const graphFontSizeSelect = document.getElementById("graphFontSize");
|
||
const colorPreview = document.getElementById("colorPreview");
|
||
const runBtn = document.getElementById("runBtn");
|
||
const exportFailedExamplesBtn = document.getElementById("exportFailedExamplesBtn");
|
||
const viewer = document.getElementById("viewer");
|
||
const viewerTitle = document.getElementById("viewerTitle");
|
||
const viewerHint = document.getElementById("viewerHint");
|
||
const viewerBody = document.getElementById("viewerBody");
|
||
const closeViewerBtn = document.getElementById("closeViewerBtn");
|
||
const zoomInBtn = document.getElementById("zoomInBtn");
|
||
const zoomOutBtn = document.getElementById("zoomOutBtn");
|
||
const zoomResetBtn = document.getElementById("zoomResetBtn");
|
||
const queueRunning = document.getElementById("queueRunning");
|
||
const queueQueued = document.getElementById("queueQueued");
|
||
const queueConcurrency = document.getElementById("queueConcurrency");
|
||
const queueThreads = document.getElementById("queueThreads");
|
||
const HISTORY_KEY = "np_replica_history_v1";
|
||
const HISTORY_MAX_ITEMS = 32;
|
||
const FAILED_EXAMPLES_KEY = "np_replica_failed_examples_v1";
|
||
const FAILED_EXAMPLES_MAX_ITEMS = 32;
|
||
const designDomainTemplate = document.getElementById("designDomainTemplate");
|
||
const designDomainList = document.getElementById("designDomainList");
|
||
const designComplexTemplate = document.getElementById("designComplexTemplate");
|
||
const designTubeTemplate = document.getElementById("designTubeTemplate");
|
||
const designOnTargetTemplate = document.getElementById("designOnTargetTemplate");
|
||
const hardConstraintTemplate = document.getElementById("hardConstraintTemplate");
|
||
const softConstraintTemplate = document.getElementById("softConstraintTemplate");
|
||
const defectWeightTemplate = document.getElementById("defectWeightTemplate");
|
||
const designComplexNames = document.getElementById("designComplexNames");
|
||
const designEntityNames = document.getElementById("designEntityNames");
|
||
const designTabButtons = Array.from(document.querySelectorAll("[data-design-tab]"));
|
||
|
||
let currentLang = "zh";
|
||
let currentTheme = localStorage.getItem("np_replica_theme") || "linen";
|
||
if (!["linen", "graphite", "tide", "ember", "sage", "harbor", "studio"].includes(currentTheme)) {
|
||
currentTheme = currentTheme === "dark" ? "graphite" : "linen";
|
||
}
|
||
let currentLayout = localStorage.getItem("np_replica_layout") || "balanced";
|
||
if (!["balanced", "editorial", "dashboard", "stacked"].includes(currentLayout)) {
|
||
currentLayout = "balanced";
|
||
}
|
||
let currentHelpVisibility = localStorage.getItem("np_replica_help_visibility") || "hidden";
|
||
if (!["hidden", "shown"].includes(currentHelpVisibility)) {
|
||
currentHelpVisibility = "hidden";
|
||
}
|
||
let heatmapColors = {
|
||
low: localStorage.getItem("np_replica_color_low") || "#f7f4e7",
|
||
high: localStorage.getItem("np_replica_color_high") || "#579b3b",
|
||
};
|
||
const GRAPH_FONT_SIZES = {
|
||
small: { mfe: 9, forceSmall: 7, forceMedium: 8, forceLarge: 9 },
|
||
normal: { mfe: 11, forceSmall: 7, forceMedium: 8, forceLarge: 10 },
|
||
large: { mfe: 14, forceSmall: 9, forceMedium: 10, forceLarge: 12 },
|
||
xlarge: { mfe: 18, forceSmall: 11, forceMedium: 13, forceLarge: 15 },
|
||
};
|
||
let currentGraphFontSize = localStorage.getItem("np_replica_graph_font_size") || "normal";
|
||
if (!GRAPH_FONT_SIZES[currentGraphFontSize]) currentGraphFontSize = "normal";
|
||
const PROBABILITY_STOPS = [
|
||
[0, "#5b2a86"],
|
||
[0.16, "#3b74d8"],
|
||
[0.34, "#36d8d8"],
|
||
[0.52, "#84f547"],
|
||
[0.68, "#fee838"],
|
||
[0.84, "#fdae61"],
|
||
[0.93, "#f46d43"],
|
||
[1, "#b40426"],
|
||
];
|
||
let lastPayload = null;
|
||
let lastResult = null;
|
||
let currentJobId = null;
|
||
let activeSubmission = false;
|
||
let cancellationRequested = false;
|
||
let probabilitySvgCache = new Map();
|
||
let styledSvgCache = new Map();
|
||
let viewerScale = 1;
|
||
let viewerPanX = 0;
|
||
let viewerPanY = 0;
|
||
let viewerDragState = null;
|
||
let healthPollTimer = null;
|
||
let historyRenderTimer = null;
|
||
let accountUser = null;
|
||
let accountUsage = null;
|
||
let cloudHistory = [];
|
||
let cloudHistoryTotal = 0;
|
||
let cloudShares = [];
|
||
let historyOffset = 0;
|
||
const HISTORY_PAGE_SIZE = 20;
|
||
let d3LoadPromise = null;
|
||
const HEALTH_POLL_INTERVAL_MS = 5000;
|
||
const D3_URL = "https://d3js.org/d3.v7.min.js";
|
||
|
||
function t(key) {
|
||
return I18N[currentLang][key] || key;
|
||
}
|
||
|
||
function setStatus(text, isError = false) {
|
||
statusBadge.textContent = text;
|
||
statusBadge.className = isError ? "pill error" : "pill";
|
||
}
|
||
|
||
function runButtonBaseLabel() {
|
||
return workflowSelect.value === "design" ? t("run_design_btn") : t("run_btn");
|
||
}
|
||
|
||
function updateRunButtonState() {
|
||
runBtn.disabled = activeSubmission && (!currentJobId || cancellationRequested);
|
||
runBtn.setAttribute("aria-busy", activeSubmission ? "true" : "false");
|
||
runBtn.classList.toggle("primary", !activeSubmission);
|
||
runBtn.classList.toggle("secondary", false);
|
||
runBtn.classList.toggle("danger", activeSubmission);
|
||
runBtn.textContent = activeSubmission ? t("run_waiting_btn") : runButtonBaseLabel();
|
||
}
|
||
|
||
function setSubmissionLocked(locked) {
|
||
activeSubmission = Boolean(locked);
|
||
updateRunButtonState();
|
||
}
|
||
|
||
async function cancelCurrentJob() {
|
||
if (!currentJobId || cancellationRequested) return;
|
||
cancellationRequested = true;
|
||
setStatus(formatStatus(t("status_canceling")));
|
||
updateRunButtonState();
|
||
try {
|
||
const { response, data } = await fetchJsonOrThrow(`/api/jobs/${encodeURIComponent(currentJobId)}/cancel`, {
|
||
method: "POST",
|
||
});
|
||
if (!response.ok || data.status !== "success") {
|
||
throw new Error(data?.error || `Cancel failed with HTTP ${response.status}`);
|
||
}
|
||
refreshHealth();
|
||
} catch (error) {
|
||
cancellationRequested = false;
|
||
setStatus(`${t("cancel_job_failed")}: ${error.message}`, true);
|
||
} finally {
|
||
updateRunButtonState();
|
||
}
|
||
}
|
||
|
||
function setQueueMetric(node, value) {
|
||
node.textContent = value === undefined || value === null ? "-" : String(value);
|
||
}
|
||
|
||
function summarizeResponseText(text, maxLength = 220) {
|
||
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
||
if (!normalized) return "(empty response body)";
|
||
return normalized.length > maxLength
|
||
? `${normalized.slice(0, maxLength)}...`
|
||
: normalized;
|
||
}
|
||
|
||
async function fetchJsonOrThrow(url, options = {}) {
|
||
const response = await fetch(url, options);
|
||
if (response.status === 401 && !url.startsWith("/api/shares/")) {
|
||
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||
window.location.assign(`/auth/login?next=${encodeURIComponent(next)}`);
|
||
}
|
||
const rawText = await response.text();
|
||
const contentType = response.headers.get("content-type") || "";
|
||
const hasBody = rawText.trim().length > 0;
|
||
|
||
if (!hasBody) {
|
||
throw new Error(`HTTP ${response.status} ${response.statusText || ""} returned an empty response body`.trim());
|
||
}
|
||
|
||
try {
|
||
return {
|
||
response,
|
||
data: JSON.parse(rawText),
|
||
};
|
||
} catch {
|
||
throw new Error(
|
||
`HTTP ${response.status} ${response.statusText || ""} returned non-JSON content (${contentType || "unknown content-type"}): ${summarizeResponseText(rawText)}`
|
||
);
|
||
}
|
||
}
|
||
|
||
async function refreshHealth() {
|
||
if (document.visibilityState === "hidden") return;
|
||
try {
|
||
const { response, data } = await fetchJsonOrThrow("/health");
|
||
if (!response.ok) return;
|
||
setQueueMetric(queueRunning, data.jobs_running);
|
||
setQueueMetric(queueQueued, data.jobs_queued);
|
||
setQueueMetric(queueConcurrency, data.worker_concurrency);
|
||
setQueueMetric(queueThreads, data.per_job_thread_limit);
|
||
} catch {
|
||
setQueueMetric(queueRunning, "-");
|
||
setQueueMetric(queueQueued, "-");
|
||
}
|
||
}
|
||
|
||
function startHealthPolling() {
|
||
if (healthPollTimer) clearInterval(healthPollTimer);
|
||
refreshHealth();
|
||
healthPollTimer = setInterval(refreshHealth, HEALTH_POLL_INTERVAL_MS);
|
||
}
|
||
|
||
function loadD3() {
|
||
if (window.d3) return Promise.resolve(window.d3);
|
||
if (d3LoadPromise) return d3LoadPromise;
|
||
d3LoadPromise = new Promise((resolve, reject) => {
|
||
const script = document.createElement("script");
|
||
script.src = D3_URL;
|
||
script.async = true;
|
||
script.onload = () => resolve(window.d3);
|
||
script.onerror = () => reject(new Error("D3.js load failed."));
|
||
document.head.appendChild(script);
|
||
});
|
||
return d3LoadPromise;
|
||
}
|
||
|
||
function hexToRgb(hex) {
|
||
const normalized = String(hex || "").trim().replace(/^#/, "");
|
||
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return { r: 0, g: 0, b: 0 };
|
||
return {
|
||
r: Number.parseInt(normalized.slice(0, 2), 16),
|
||
g: Number.parseInt(normalized.slice(2, 4), 16),
|
||
b: Number.parseInt(normalized.slice(4, 6), 16),
|
||
};
|
||
}
|
||
|
||
function rgbToCss(rgb) {
|
||
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
||
}
|
||
|
||
function blendColor(lowHex, highHex, ratio) {
|
||
const tRatio = Math.max(0, Math.min(1, Number(ratio) || 0));
|
||
const low = hexToRgb(lowHex);
|
||
const high = hexToRgb(highHex);
|
||
return rgbToCss({
|
||
r: Math.round(low.r + (high.r - low.r) * tRatio),
|
||
g: Math.round(low.g + (high.g - low.g) * tRatio),
|
||
b: Math.round(low.b + (high.b - low.b) * tRatio),
|
||
});
|
||
}
|
||
|
||
function probabilityColorForValue(value) {
|
||
const tRatio = Math.max(0, Math.min(1, Number(value) || 0));
|
||
for (let i = 1; i < PROBABILITY_STOPS.length; i += 1) {
|
||
const [stop, stopColor] = PROBABILITY_STOPS[i];
|
||
const [prevStop, prevColor] = PROBABILITY_STOPS[i - 1];
|
||
if (tRatio <= stop) {
|
||
const localRatio = stop === prevStop ? 0 : (tRatio - prevStop) / (stop - prevStop);
|
||
return blendColor(prevColor, stopColor, localRatio);
|
||
}
|
||
}
|
||
return PROBABILITY_STOPS[PROBABILITY_STOPS.length - 1][1];
|
||
}
|
||
|
||
function luminanceFromHex(hex) {
|
||
const { r, g, b } = hexToRgb(hex);
|
||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||
}
|
||
|
||
function probabilityTextStroke(value) {
|
||
const fill = probabilityColorForValue(value);
|
||
return luminanceFromHex(fill) > 0.62 ? "rgba(26, 31, 28, 0.82)" : "rgba(255,255,255,0.92)";
|
||
}
|
||
|
||
function colorForProbability(value) {
|
||
return blendColor(heatmapColors.low, heatmapColors.high, value);
|
||
}
|
||
|
||
function cssVar(name, fallback = "") {
|
||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||
}
|
||
|
||
function applyTheme() {
|
||
document.documentElement.setAttribute("data-theme", currentTheme);
|
||
localStorage.setItem("np_replica_theme", currentTheme);
|
||
themeSelect.value = currentTheme;
|
||
}
|
||
|
||
function applyLayout() {
|
||
document.documentElement.setAttribute("data-layout", currentLayout);
|
||
localStorage.setItem("np_replica_layout", currentLayout);
|
||
layoutSelect.value = currentLayout;
|
||
}
|
||
|
||
function applyHelpVisibility() {
|
||
document.documentElement.setAttribute("data-help-visibility", currentHelpVisibility);
|
||
localStorage.setItem("np_replica_help_visibility", currentHelpVisibility);
|
||
helpToggle.value = currentHelpVisibility;
|
||
}
|
||
|
||
function applyHeatmapColors() {
|
||
document.documentElement.style.setProperty("--heatmap-low", heatmapColors.low);
|
||
document.documentElement.style.setProperty("--heatmap-high", heatmapColors.high);
|
||
document.documentElement.style.setProperty("--heatmap-gradient", `linear-gradient(90deg, ${heatmapColors.low} 0%, ${heatmapColors.high} 100%)`);
|
||
document.documentElement.style.setProperty("--probability-gradient", "linear-gradient(180deg, #b40426 0%, #f46d43 14%, #fdae61 28%, #fee838 42%, #84f547 56%, #36d8d8 70%, #3b74d8 84%, #5b2a86 100%)");
|
||
colorPreview.style.background = `linear-gradient(90deg, ${heatmapColors.low} 0%, ${heatmapColors.high} 100%)`;
|
||
}
|
||
|
||
function graphFontConfig() {
|
||
return GRAPH_FONT_SIZES[currentGraphFontSize] || GRAPH_FONT_SIZES.normal;
|
||
}
|
||
|
||
function forceLabelFontSize(nodeCount) {
|
||
const config = graphFontConfig();
|
||
if (nodeCount > 220) return config.forceSmall;
|
||
if (nodeCount > 140) return config.forceMedium;
|
||
return config.forceLarge;
|
||
}
|
||
|
||
function applyGraphFontSize() {
|
||
graphFontSizeSelect.value = currentGraphFontSize;
|
||
localStorage.setItem("np_replica_graph_font_size", currentGraphFontSize);
|
||
}
|
||
|
||
function refreshStylesFromControls() {
|
||
heatmapColors = {
|
||
low: colorLowInput.value,
|
||
high: colorHighInput.value,
|
||
};
|
||
localStorage.setItem("np_replica_color_low", heatmapColors.low);
|
||
localStorage.setItem("np_replica_color_high", heatmapColors.high);
|
||
applyHeatmapColors();
|
||
if (lastResult) {
|
||
renderResult(lastResult);
|
||
setStatus(formatStatus(t("status_done")));
|
||
}
|
||
}
|
||
|
||
function refreshGraphFontSize() {
|
||
currentGraphFontSize = graphFontSizeSelect.value;
|
||
applyGraphFontSize();
|
||
if (lastResult) {
|
||
renderResult(lastResult);
|
||
setStatus(formatStatus(t("status_done")));
|
||
}
|
||
}
|
||
|
||
function formatStatus(text) {
|
||
return currentJobId ? `${text} · ${t("current_job")}: ${currentJobId}` : text;
|
||
}
|
||
|
||
function workflowKind(result) {
|
||
return result?.workflow === "design" ? "design" : "analysis";
|
||
}
|
||
|
||
function applyTranslations() {
|
||
document.documentElement.lang = currentLang === "zh" ? "zh-CN" : "en";
|
||
document.title = t("hero_title");
|
||
viewerTitle.textContent = t("viewer_title");
|
||
viewerHint.textContent = t("viewer_hint");
|
||
applyTheme();
|
||
applyLayout();
|
||
applyHelpVisibility();
|
||
closeViewerBtn.textContent = t("close_viewer");
|
||
zoomInBtn.textContent = t("zoom_in");
|
||
zoomOutBtn.textContent = t("zoom_out");
|
||
zoomResetBtn.textContent = t("zoom_reset");
|
||
colorLowInput.value = heatmapColors.low;
|
||
colorHighInput.value = heatmapColors.high;
|
||
applyHeatmapColors();
|
||
applyGraphFontSize();
|
||
document.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
const key = node.dataset.i18n;
|
||
node.textContent = t(key);
|
||
});
|
||
document.querySelectorAll("[data-i18n-placeholder]").forEach((node) => {
|
||
node.placeholder = t(node.dataset.i18nPlaceholder);
|
||
});
|
||
updateFailedExamplesButtonState();
|
||
if (lastResult) {
|
||
renderResult(lastResult);
|
||
setStatus(formatStatus(t("status_done")));
|
||
} else {
|
||
renderEmptyState();
|
||
}
|
||
syncWorkflow();
|
||
syncMode();
|
||
}
|
||
|
||
function scheduleRenderHistory() {
|
||
if (historyRenderTimer) return;
|
||
const render = () => {
|
||
historyRenderTimer = null;
|
||
renderHistory();
|
||
};
|
||
if (window.requestIdleCallback) {
|
||
historyRenderTimer = window.requestIdleCallback(render, { timeout: 1200 });
|
||
} else {
|
||
historyRenderTimer = window.setTimeout(render, 80);
|
||
}
|
||
}
|
||
|
||
function renderEmptyState() {
|
||
results.innerHTML = `
|
||
<div class="result-card">
|
||
<h3>${t("ready_title")}</h3>
|
||
<p>${t("ready_desc")}</p>
|
||
</div>
|
||
`;
|
||
setStatus(formatStatus(t("status_idle")));
|
||
scheduleRenderHistory();
|
||
}
|
||
|
||
function addStrandRow(data = {}) {
|
||
const fragment = strandTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
card.querySelector('[data-field="name"]').value = data.name || "";
|
||
card.querySelector('[data-field="sequence"]').value = data.sequence ?? data.definition ?? "";
|
||
card.querySelector('[data-field="concentration"]').value = data.concentration ?? 1.0;
|
||
card.querySelector('[data-field="unit"]').value = data.unit || "uM";
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector("[data-remove]").addEventListener("click", () => card.remove());
|
||
strandList.appendChild(fragment);
|
||
syncWorkflow();
|
||
}
|
||
|
||
function getStrands() {
|
||
return Array.from(strandList.querySelectorAll(".strand-card")).map((card) => ({
|
||
name: card.querySelector('[data-field="name"]').value.trim(),
|
||
sequence: card.querySelector('[data-field="sequence"]').value.trim(),
|
||
concentration: Number(card.querySelector('[data-field="concentration"]').value),
|
||
unit: card.querySelector('[data-field="unit"]').value,
|
||
}));
|
||
}
|
||
|
||
function addDesignDomainRow(data = {}) {
|
||
const fragment = designDomainTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
card.querySelector('[data-field="name"]').value = data.name || "";
|
||
card.querySelector('[data-field="sequence"]').value = data.sequence || "";
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector("[data-remove]").addEventListener("click", () => {
|
||
card.remove();
|
||
updateDesignReferenceLists();
|
||
});
|
||
card.querySelectorAll("input").forEach((input) => input.addEventListener("input", updateDesignReferenceLists));
|
||
designDomainList.appendChild(fragment);
|
||
updateDesignReferenceLists();
|
||
}
|
||
|
||
function getDesignDomains() {
|
||
return Array.from(designDomainList.querySelectorAll(".strand-card")).map((card) => ({
|
||
name: card.querySelector('[data-field="name"]').value.trim(),
|
||
sequence: card.querySelector('[data-field="sequence"]').value.trim(),
|
||
})).filter((row) => row.name || row.sequence);
|
||
}
|
||
|
||
function addComplexRow(value = "") {
|
||
const fragment = complexTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
card.querySelector('[data-field="complex"]').value = value;
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector("[data-remove]").addEventListener("click", () => {
|
||
card.remove();
|
||
syncComplexTextFromRows();
|
||
});
|
||
card.querySelector('[data-field="complex"]').addEventListener("input", syncComplexTextFromRows);
|
||
complexList.appendChild(fragment);
|
||
}
|
||
|
||
function updateDesignReferenceLists() {
|
||
const complexNames = Array.from(designComplexList.querySelectorAll('.strand-card [data-field="name"]'))
|
||
.map((input) => input.value.trim())
|
||
.filter(Boolean);
|
||
designComplexNames.innerHTML = complexNames.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
|
||
|
||
const entityNames = [
|
||
...getDesignDomains().map((row) => row.name),
|
||
...getStrands().map((row) => row.name),
|
||
...complexNames,
|
||
...Array.from(designTubeList.querySelectorAll('.strand-card [data-field="name"]')).map((input) => input.value.trim()).filter(Boolean),
|
||
];
|
||
designEntityNames.innerHTML = entityNames.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
|
||
}
|
||
|
||
function addDesignComplexRow(data = {}) {
|
||
const fragment = designComplexTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
card.querySelector('[data-field="name"]').value = data.name || "";
|
||
card.querySelector('[data-field="strands"]').value = data.strands || "";
|
||
card.querySelector('[data-field="structure"]').value = data.structure || "";
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector("[data-remove]").addEventListener("click", () => {
|
||
card.remove();
|
||
updateDesignReferenceLists();
|
||
});
|
||
card.querySelectorAll("input").forEach((input) => {
|
||
input.addEventListener("input", updateDesignReferenceLists);
|
||
});
|
||
designComplexList.appendChild(fragment);
|
||
updateDesignReferenceLists();
|
||
}
|
||
|
||
function getDesignComplexes() {
|
||
return Array.from(designComplexList.querySelectorAll(".strand-card")).map((card) => ({
|
||
name: card.querySelector('[data-field="name"]').value.trim(),
|
||
strands: card.querySelector('[data-field="strands"]').value.trim(),
|
||
structure: card.querySelector('[data-field="structure"]').value.trim(),
|
||
})).filter((row) => row.name || row.strands || row.structure);
|
||
}
|
||
|
||
function addDesignOnTargetRow(container, data = {}) {
|
||
const fragment = designOnTargetTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".nested-card");
|
||
card.querySelector('[data-field="complex"]').value = data.complex || "";
|
||
card.querySelector('[data-field="concentration"]').value = data.concentration ?? 1.0;
|
||
card.querySelector('[data-field="unit"]').value = data.unit || "uM";
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector("[data-remove]").addEventListener("click", () => card.remove());
|
||
container.appendChild(fragment);
|
||
}
|
||
|
||
function addDesignTubeRow(data = {}) {
|
||
const fragment = designTubeTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
const onTargetList = card.querySelector("[data-on-target-list]");
|
||
card.querySelector('[data-field="name"]').value = data.name || "";
|
||
card.querySelector('[data-field="max_size"]').value = data.max_size ?? 2;
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector("[data-add-on-target]").addEventListener("click", () => {
|
||
addDesignOnTargetRow(onTargetList, { concentration: 1.0, unit: "uM" });
|
||
});
|
||
card.querySelector("[data-remove]").addEventListener("click", () => {
|
||
card.remove();
|
||
updateDesignReferenceLists();
|
||
});
|
||
card.querySelectorAll('input[data-field="name"]').forEach((input) => {
|
||
input.addEventListener("input", updateDesignReferenceLists);
|
||
});
|
||
const onTargets = Array.isArray(data.on_targets) && data.on_targets.length ? data.on_targets : [{ concentration: 1.0, unit: "uM" }];
|
||
onTargets.forEach((entry) => addDesignOnTargetRow(onTargetList, entry));
|
||
designTubeList.appendChild(fragment);
|
||
updateDesignReferenceLists();
|
||
}
|
||
|
||
function getDesignTubes() {
|
||
return Array.from(designTubeList.querySelectorAll(".strand-card")).map((card) => ({
|
||
name: card.querySelector('[data-field="name"]').value.trim(),
|
||
max_size: Number(card.querySelector('[data-field="max_size"]').value),
|
||
on_targets: Array.from(card.querySelectorAll(".nested-card")).map((row) => ({
|
||
complex: row.querySelector('[data-field="complex"]').value.trim(),
|
||
concentration: Number(row.querySelector('[data-field="concentration"]').value),
|
||
unit: row.querySelector('[data-field="unit"]').value,
|
||
})).filter((row) => row.complex || row.concentration),
|
||
})).filter((row) => row.name || row.on_targets.length);
|
||
}
|
||
|
||
function syncHardConstraintCard(card) {
|
||
const type = card.querySelector('[data-field="type"]').value;
|
||
const scopeInput = card.querySelector('[data-field="scope"]');
|
||
card.querySelectorAll("[data-hard-field]").forEach((node) => node.classList.add("hidden"));
|
||
const show = (...names) => names.forEach((name) => card.querySelector(`[data-hard-field="${name}"]`)?.classList.remove("hidden"));
|
||
if (type === "match") show("left", "right");
|
||
if (type === "complementarity") show("left", "right", "wobble_mutations");
|
||
if (type === "diversity") {
|
||
show("scope", "word", "types");
|
||
if (scopeInput) scopeInput.placeholder = "留空表示 global;也可用 A 或 a";
|
||
}
|
||
if (type === "similarity") {
|
||
show("scope", "reference", "min_fraction", "max_fraction");
|
||
if (scopeInput) scopeInput.placeholder = "A 或 a";
|
||
}
|
||
if (type === "window") {
|
||
show("scope", "sources");
|
||
if (scopeInput) scopeInput.placeholder = "A 或 a";
|
||
}
|
||
if (type === "library") {
|
||
show("scope", "catalog");
|
||
if (scopeInput) scopeInput.placeholder = "A 或 a";
|
||
}
|
||
if (type === "pattern") {
|
||
show("scope", "patterns");
|
||
if (scopeInput) scopeInput.placeholder = "留空表示 global;也可用 A 或 a";
|
||
}
|
||
}
|
||
|
||
function addHardConstraintRow(data = {}) {
|
||
const fragment = hardConstraintTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
const defaults = {
|
||
type: "match",
|
||
left: "",
|
||
right: "",
|
||
scope: "",
|
||
reference: "",
|
||
sources: "",
|
||
catalog: "",
|
||
patterns: "",
|
||
min_fraction: 0.45,
|
||
max_fraction: 0.55,
|
||
word: 4,
|
||
types: 2,
|
||
wobble_mutations: false,
|
||
...data,
|
||
};
|
||
Object.entries(defaults).forEach(([key, value]) => {
|
||
const field = card.querySelector(`[data-field="${key}"]`);
|
||
if (!field) return;
|
||
if (field.tagName === "SELECT") {
|
||
field.value = String(value);
|
||
} else if (field.type === "number") {
|
||
field.value = value;
|
||
} else {
|
||
field.value = value ?? "";
|
||
}
|
||
});
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector('[data-field="type"]').addEventListener("change", () => syncHardConstraintCard(card));
|
||
card.querySelector("[data-remove]").addEventListener("click", () => card.remove());
|
||
syncHardConstraintCard(card);
|
||
hardConstraintList.appendChild(fragment);
|
||
}
|
||
|
||
function getHardConstraints() {
|
||
return Array.from(hardConstraintList.querySelectorAll(".strand-card")).map((card) => ({
|
||
type: card.querySelector('[data-field="type"]').value,
|
||
left: card.querySelector('[data-field="left"]')?.value.trim(),
|
||
right: card.querySelector('[data-field="right"]')?.value.trim(),
|
||
scope: card.querySelector('[data-field="scope"]')?.value.trim(),
|
||
reference: card.querySelector('[data-field="reference"]')?.value.trim(),
|
||
sources: card.querySelector('[data-field="sources"]')?.value.trim(),
|
||
catalog: card.querySelector('[data-field="catalog"]')?.value.trim(),
|
||
patterns: card.querySelector('[data-field="patterns"]')?.value.trim(),
|
||
min_fraction: Number(card.querySelector('[data-field="min_fraction"]')?.value),
|
||
max_fraction: Number(card.querySelector('[data-field="max_fraction"]')?.value),
|
||
word: Number(card.querySelector('[data-field="word"]')?.value),
|
||
types: Number(card.querySelector('[data-field="types"]')?.value),
|
||
wobble_mutations: card.querySelector('[data-field="wobble_mutations"]')?.value === "true",
|
||
})).filter((row) => row.type);
|
||
}
|
||
|
||
function syncSoftConstraintCard(card) {
|
||
const type = card.querySelector('[data-field="type"]').value;
|
||
const scopeInput = card.querySelector('[data-field="scope"]');
|
||
card.querySelectorAll("[data-soft-field]").forEach((node) => node.classList.add("hidden"));
|
||
const show = (...names) => names.forEach((name) => card.querySelector(`[data-soft-field="${name}"]`)?.classList.remove("hidden"));
|
||
if (type === "pattern") {
|
||
show("scope", "patterns", "weight");
|
||
if (scopeInput) scopeInput.placeholder = "留空表示 global;也可用 A 或 a b";
|
||
}
|
||
if (type === "similarity") {
|
||
show("scope", "reference", "min_fraction", "max_fraction", "weight");
|
||
if (scopeInput) scopeInput.placeholder = "A 或 a b";
|
||
}
|
||
if (type === "ssm") {
|
||
show("scope", "word", "weight");
|
||
if (scopeInput) scopeInput.placeholder = "Target complex 名称,如 AB_target";
|
||
}
|
||
if (type === "energy_match") {
|
||
show("scope", "energy_ref", "weight");
|
||
if (scopeInput) scopeInput.placeholder = "domains,如 a b";
|
||
}
|
||
}
|
||
|
||
function addSoftConstraintRow(data = {}) {
|
||
const fragment = softConstraintTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
const defaults = {
|
||
type: "pattern",
|
||
scope: "",
|
||
reference: "",
|
||
patterns: "",
|
||
min_fraction: 0.45,
|
||
max_fraction: 0.55,
|
||
word: 4,
|
||
energy_ref: "",
|
||
weight: 1.0,
|
||
...data,
|
||
};
|
||
Object.entries(defaults).forEach(([key, value]) => {
|
||
const field = card.querySelector(`[data-field="${key}"]`);
|
||
if (!field) return;
|
||
field.value = value ?? "";
|
||
});
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector('[data-field="type"]').addEventListener("change", () => syncSoftConstraintCard(card));
|
||
card.querySelector("[data-remove]").addEventListener("click", () => card.remove());
|
||
syncSoftConstraintCard(card);
|
||
softConstraintList.appendChild(fragment);
|
||
}
|
||
|
||
function getSoftConstraints() {
|
||
return Array.from(softConstraintList.querySelectorAll(".strand-card")).map((card) => ({
|
||
type: card.querySelector('[data-field="type"]').value,
|
||
scope: card.querySelector('[data-field="scope"]')?.value.trim(),
|
||
reference: card.querySelector('[data-field="reference"]')?.value.trim(),
|
||
patterns: card.querySelector('[data-field="patterns"]')?.value.trim(),
|
||
min_fraction: Number(card.querySelector('[data-field="min_fraction"]')?.value),
|
||
max_fraction: Number(card.querySelector('[data-field="max_fraction"]')?.value),
|
||
word: Number(card.querySelector('[data-field="word"]')?.value),
|
||
energy_ref: card.querySelector('[data-field="energy_ref"]')?.value.trim(),
|
||
weight: Number(card.querySelector('[data-field="weight"]')?.value),
|
||
})).filter((row) => row.type);
|
||
}
|
||
|
||
function syncDefectWeightCard(card) {
|
||
const kind = card.querySelector('[data-field="kind"]').value;
|
||
const nameField = card.querySelector("[data-weight-name]");
|
||
nameField.classList.toggle("hidden", kind === "global");
|
||
}
|
||
|
||
function addDefectWeightRow(data = {}) {
|
||
const fragment = defectWeightTemplate.content.cloneNode(true);
|
||
const card = fragment.querySelector(".strand-card");
|
||
card.querySelector('[data-field="kind"]').value = data.kind || "global";
|
||
card.querySelector('[data-field="name"]').value = data.name || "";
|
||
card.querySelector('[data-field="weight"]').value = data.weight ?? 1.0;
|
||
card.querySelectorAll("[data-i18n]").forEach((node) => {
|
||
node.textContent = t(node.dataset.i18n);
|
||
});
|
||
card.querySelector('[data-field="kind"]').addEventListener("change", () => syncDefectWeightCard(card));
|
||
card.querySelector("[data-remove]").addEventListener("click", () => card.remove());
|
||
syncDefectWeightCard(card);
|
||
defectWeightList.appendChild(fragment);
|
||
}
|
||
|
||
function getDefectWeights() {
|
||
return Array.from(defectWeightList.querySelectorAll(".strand-card")).map((card) => ({
|
||
kind: card.querySelector('[data-field="kind"]').value,
|
||
name: card.querySelector('[data-field="name"]').value.trim(),
|
||
weight: Number(card.querySelector('[data-field="weight"]').value),
|
||
})).filter((row) => row.kind && (row.kind === "global" || row.name));
|
||
}
|
||
|
||
function setActiveDesignTab(value) {
|
||
designTabButtons.forEach((button) => button.classList.toggle("active", button.dataset.designTab === value));
|
||
document.querySelectorAll("[data-design-panel]").forEach((panel) => {
|
||
panel.classList.toggle("hidden", panel.dataset.designPanel !== value);
|
||
});
|
||
}
|
||
|
||
function addHardConstraintPreset(kind) {
|
||
const presets = {
|
||
match: { type: "match", left: "a", right: "a" },
|
||
complementarity: { type: "complementarity", left: "A", right: "B", wobble_mutations: false },
|
||
similarity: { type: "similarity", scope: "a", reference: "R10", min_fraction: 0.2, max_fraction: 0.8 },
|
||
window: { type: "window", scope: "a", sources: "AUGCAUGCAU\nUGCAUGCAUG" },
|
||
};
|
||
if (presets[kind]) addHardConstraintRow(presets[kind]);
|
||
}
|
||
|
||
function addSoftConstraintPreset(kind) {
|
||
const presets = {
|
||
pattern: { type: "pattern", scope: "", patterns: "AAAA, UUUU", weight: 0.4 },
|
||
similarity: { type: "similarity", scope: "a", reference: "R10", min_fraction: 0.2, max_fraction: 0.8, weight: 0.3 },
|
||
ssm: { type: "ssm", scope: "AB_target", word: 4, weight: 0.2 },
|
||
energy_match: { type: "energy_match", scope: "a", weight: 0.2 },
|
||
};
|
||
if (presets[kind]) addSoftConstraintRow(presets[kind]);
|
||
}
|
||
|
||
function syncComplexTextFromRows() {
|
||
const lines = Array.from(complexList.querySelectorAll('[data-field="complex"]'))
|
||
.map((input) => input.value.trim())
|
||
.filter(Boolean);
|
||
document.getElementById("complexesText").value = lines.join("\n");
|
||
}
|
||
|
||
function syncComplexRowsFromText(text) {
|
||
complexList.innerHTML = "";
|
||
const lines = String(text || "").split("\n").map((line) => line.trim()).filter(Boolean);
|
||
if (!lines.length) {
|
||
addComplexRow("");
|
||
} else {
|
||
lines.forEach((line) => addComplexRow(line));
|
||
}
|
||
syncComplexTextFromRows();
|
||
}
|
||
|
||
function downloadText(filename, content, mime = "application/json;charset=utf-8") {
|
||
const blob = new Blob([content], { type: mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function downloadSvgMarkup(filename, svgMarkup) {
|
||
if (!svgMarkup) return;
|
||
downloadText(filename, svgMarkup, "image/svg+xml;charset=utf-8");
|
||
}
|
||
|
||
function cloneJson(value) {
|
||
try {
|
||
return JSON.parse(JSON.stringify(value));
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
function getFailureExamples() {
|
||
try {
|
||
const failures = JSON.parse(localStorage.getItem(FAILED_EXAMPLES_KEY) || "[]");
|
||
return Array.isArray(failures) ? failures : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function setFailureExamples(failures) {
|
||
let trimmed = failures.slice(0, FAILED_EXAMPLES_MAX_ITEMS);
|
||
while (trimmed.length) {
|
||
try {
|
||
localStorage.setItem(FAILED_EXAMPLES_KEY, JSON.stringify(trimmed));
|
||
return;
|
||
} catch {
|
||
trimmed = trimmed.slice(0, -1);
|
||
}
|
||
}
|
||
localStorage.removeItem(FAILED_EXAMPLES_KEY);
|
||
}
|
||
|
||
function updateFailedExamplesButtonState() {
|
||
const count = getFailureExamples().length;
|
||
exportFailedExamplesBtn.disabled = count === 0;
|
||
exportFailedExamplesBtn.title = count ? `${t("export_failed_examples")} (${count})` : t("no_failed_examples");
|
||
}
|
||
|
||
function normalizeErrorForExport(error) {
|
||
const normalized = {
|
||
message: error?.message || String(error || "Unknown error"),
|
||
name: error?.name || "Error",
|
||
};
|
||
if (error?.stack) normalized.stack = String(error.stack);
|
||
return normalized;
|
||
}
|
||
|
||
function saveFailedExample(payload, error, context = {}) {
|
||
const safePayload = payload ? cloneJson(payload) : null;
|
||
const failure = {
|
||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||
created_at: new Date().toISOString(),
|
||
workflow: context.workflow || safePayload?.workflow || workflowSelect.value,
|
||
mode: context.mode || safePayload?.mode || modeSelect.value,
|
||
stage: context.stage || "job",
|
||
job_id: context.jobId || currentJobId || null,
|
||
error: normalizeErrorForExport(error),
|
||
payload: safePayload,
|
||
};
|
||
setFailureExamples([failure, ...getFailureExamples()]);
|
||
updateFailedExamplesButtonState();
|
||
return failure;
|
||
}
|
||
|
||
function exportFailedExamples() {
|
||
const failures = getFailureExamples();
|
||
if (!failures.length) {
|
||
alert(t("no_failed_examples"));
|
||
updateFailedExamplesButtonState();
|
||
return;
|
||
}
|
||
downloadText("nupack-failed-examples.json", JSON.stringify({
|
||
version: 1,
|
||
exported_at: new Date().toISOString(),
|
||
failures,
|
||
}, null, 2));
|
||
}
|
||
|
||
function getHistory() {
|
||
try {
|
||
const history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
||
return Array.isArray(history) ? history : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function setHistory(history) {
|
||
let trimmed = history.slice(0, HISTORY_MAX_ITEMS);
|
||
while (trimmed.length) {
|
||
try {
|
||
localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed));
|
||
return;
|
||
} catch (error) {
|
||
trimmed = trimmed.slice(0, -1);
|
||
}
|
||
}
|
||
localStorage.removeItem(HISTORY_KEY);
|
||
}
|
||
|
||
function buildHistorySummary(payload, fallback = {}) {
|
||
const workflow = payload?.workflow || fallback.workflow || "analysis";
|
||
const mode = payload?.mode || fallback.mode || "tube";
|
||
const designComplexCount = Array.isArray(payload?.design_complexes)
|
||
? payload.design_complexes.length
|
||
: (Array.isArray(payload?.design_targets) ? payload.design_targets.length : 0);
|
||
const analysisComplexCount = String(payload?.complexes_text || "")
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.length;
|
||
return {
|
||
workflow,
|
||
mode,
|
||
complex_count: Number(fallback.complex_count ?? (workflow === "design" ? designComplexCount : analysisComplexCount)) || 0,
|
||
compute: Array.isArray(fallback.compute) ? fallback.compute : (Array.isArray(payload?.compute) ? payload.compute : []),
|
||
};
|
||
}
|
||
|
||
function normalizeHistoryEntry(entry, index = 0) {
|
||
const payload = entry?.payload || (entry?.workflow ? entry : null);
|
||
if (!payload || !payload.workflow || !payload.mode) return null;
|
||
const result = entry?.result && typeof entry.result === "object" ? entry.result : null;
|
||
return {
|
||
id: String(entry.id || entry.job_id || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`),
|
||
job_id: entry.job_id || null,
|
||
status: entry.status || (result ? "success" : "input_only"),
|
||
created_at: entry.created_at_iso || entry.created_at || new Date().toISOString(),
|
||
updated_at: entry.updated_at || null,
|
||
elapsed_seconds: entry.elapsed_seconds ?? null,
|
||
payload,
|
||
result,
|
||
error: entry.error || null,
|
||
result_summary: buildHistorySummary(payload, entry.result_summary || {}),
|
||
};
|
||
}
|
||
|
||
function extractHistoryEntries(raw) {
|
||
const source = Array.isArray(raw) ? raw : (Array.isArray(raw?.history) ? raw.history : [raw]);
|
||
return source.map((entry, index) => normalizeHistoryEntry(entry, index)).filter(Boolean);
|
||
}
|
||
|
||
function saveHistory(payload, result) {
|
||
void refreshAccountWorkspace();
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
let bytes = Number(value) || 0;
|
||
const units = ["B", "KB", "MB", "GB"];
|
||
let unit = 0;
|
||
while (bytes >= 1024 && unit < units.length - 1) {
|
||
bytes /= 1024;
|
||
unit += 1;
|
||
}
|
||
return `${bytes.toFixed(unit ? 1 : 0)} ${units[unit]}`;
|
||
}
|
||
|
||
function formatDuration(value) {
|
||
const seconds = Number(value) || 0;
|
||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
|
||
return `${(seconds / 3600).toFixed(1)}h`;
|
||
}
|
||
|
||
function renderUsage() {
|
||
if (!accountUsage) return;
|
||
document.getElementById("usageTotal").textContent = accountUsage.total_jobs || 0;
|
||
document.getElementById("usageSuccess").textContent = accountUsage.success_jobs || 0;
|
||
document.getElementById("usageError").textContent = accountUsage.error_jobs || 0;
|
||
document.getElementById("usageActive").textContent = accountUsage.active_jobs || 0;
|
||
document.getElementById("usageCompute").textContent = formatDuration(accountUsage.compute_seconds);
|
||
document.getElementById("usageStorage").textContent = formatBytes(accountUsage.stored_bytes);
|
||
}
|
||
|
||
function historyQuery() {
|
||
const query = new URLSearchParams({ limit: String(HISTORY_PAGE_SIZE), offset: String(historyOffset) });
|
||
const filters = {
|
||
q: historySearch.value.trim(),
|
||
status: historyStatus.value,
|
||
workflow: historyWorkflow.value,
|
||
mode: historyMode.value,
|
||
material: historyMaterial.value,
|
||
};
|
||
Object.entries(filters).forEach(([key, value]) => {
|
||
if (value) query.set(key, value);
|
||
});
|
||
return query;
|
||
}
|
||
|
||
async function loadCloudHistory() {
|
||
const { response, data } = await fetchJsonOrThrow(`/api/history?${historyQuery()}`);
|
||
if (!response.ok || data.status !== "success") {
|
||
throw new Error(data?.error || `History request failed with HTTP ${response.status}`);
|
||
}
|
||
cloudHistory = data.items || [];
|
||
cloudHistoryTotal = Number(data.total) || 0;
|
||
if (historyOffset && historyOffset >= cloudHistoryTotal) {
|
||
historyOffset = Math.max(0, Math.floor(Math.max(0, cloudHistoryTotal - 1) / HISTORY_PAGE_SIZE) * HISTORY_PAGE_SIZE);
|
||
return loadCloudHistory();
|
||
}
|
||
renderHistory();
|
||
}
|
||
|
||
async function loadCloudShares() {
|
||
const { response, data } = await fetchJsonOrThrow("/api/account/shares");
|
||
if (!response.ok || data.status !== "success") {
|
||
throw new Error(data?.error || `Share request failed with HTTP ${response.status}`);
|
||
}
|
||
cloudShares = data.items || [];
|
||
renderShares();
|
||
}
|
||
|
||
async function refreshAccountWorkspace() {
|
||
if (!accountUser) return;
|
||
try {
|
||
const [me] = await Promise.all([
|
||
fetchJsonOrThrow("/api/me"),
|
||
loadCloudHistory(),
|
||
loadCloudShares(),
|
||
]);
|
||
if (me.response.ok) {
|
||
accountUsage = me.data.usage;
|
||
renderUsage();
|
||
}
|
||
} catch (error) {
|
||
setStatus(`${t("status_error")}: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
async function migrateLocalHistory() {
|
||
const entries = getHistory();
|
||
const migrationKey = `np_replica_history_migrated_${accountUser.user_id}`;
|
||
if (!entries.length || localStorage.getItem(migrationKey) === "1") return;
|
||
const { response, data } = await fetchJsonOrThrow("/api/history/import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ history: entries }),
|
||
});
|
||
if (!response.ok || data.status !== "success") {
|
||
throw new Error(data?.error || `History migration failed with HTTP ${response.status}`);
|
||
}
|
||
localStorage.setItem(migrationKey, "1");
|
||
setStatus(formatStatus(`${t("history_import_migrated")}: ${data.imported}`));
|
||
}
|
||
|
||
async function initializeAccount() {
|
||
try {
|
||
const { response, data } = await fetchJsonOrThrow("/api/me");
|
||
if (!response.ok || data.status !== "success") return;
|
||
accountUser = data.user;
|
||
accountUsage = data.usage;
|
||
accountName.textContent = accountUser.display_name || accountUser.username;
|
||
accountEmail.textContent = accountUser.email || accountUser.username;
|
||
accountChip.classList.remove("hidden");
|
||
usagePanel.classList.remove("hidden");
|
||
renderUsage();
|
||
await migrateLocalHistory();
|
||
await refreshAccountWorkspace();
|
||
} catch (error) {
|
||
setStatus(`${t("status_error")}: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
async function deleteHistory(id) {
|
||
const { response, data } = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
if (!response.ok || data.status !== "success") {
|
||
throw new Error(data?.error || `Delete failed with HTTP ${response.status}`);
|
||
}
|
||
await refreshAccountWorkspace();
|
||
}
|
||
|
||
async function fetchAllHistory() {
|
||
const items = [];
|
||
let offset = 0;
|
||
while (true) {
|
||
const query = new URLSearchParams({ limit: "100", offset: String(offset) });
|
||
const { response, data } = await fetchJsonOrThrow(`/api/history?${query}`);
|
||
if (!response.ok || data.status !== "success") throw new Error(data?.error || "History export failed.");
|
||
const page = data.items || [];
|
||
for (const summary of page) {
|
||
const detail = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(summary.job_id)}`);
|
||
if (detail.response.ok) items.push(detail.data.item);
|
||
}
|
||
offset += page.length;
|
||
if (!page.length || offset >= data.total) break;
|
||
}
|
||
return items;
|
||
}
|
||
|
||
async function exportHistory() {
|
||
const history = await fetchAllHistory();
|
||
downloadText("nupack-history.json", JSON.stringify({ version: 2, exported_at: new Date().toISOString(), history }, null, 2));
|
||
}
|
||
|
||
async function exportHistoryItem(id) {
|
||
const { response, data } = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(id)}`);
|
||
if (!response.ok || data.status !== "success") throw new Error(data?.error || "History export failed.");
|
||
downloadText(`nupack-history-${id}.json`, JSON.stringify(data.item, null, 2));
|
||
}
|
||
|
||
async function importHistoryFiles(files) {
|
||
const imported = [];
|
||
for (const file of Array.from(files || [])) {
|
||
imported.push(...extractHistoryEntries(JSON.parse(await file.text())));
|
||
}
|
||
if (!imported.length) throw new Error("No valid history entries found.");
|
||
const { response, data } = await fetchJsonOrThrow("/api/history/import", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ history: imported }),
|
||
});
|
||
if (!response.ok || data.status !== "success") throw new Error(data?.error || "History import failed.");
|
||
historyOffset = 0;
|
||
await refreshAccountWorkspace();
|
||
setStatus(formatStatus(`${t("history_import_success")}: ${data.imported}`));
|
||
}
|
||
|
||
async function copyTextToClipboard(text) {
|
||
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
|
||
const textarea = document.createElement("textarea");
|
||
textarea.value = text;
|
||
textarea.style.position = "fixed";
|
||
textarea.style.left = "-9999px";
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
const copied = document.execCommand("copy");
|
||
textarea.remove();
|
||
if (!copied) throw new Error("Clipboard copy failed.");
|
||
}
|
||
|
||
function shareUrl(shareId) {
|
||
return `${window.location.origin}/share/${encodeURIComponent(shareId)}`;
|
||
}
|
||
|
||
async function shareHistoryItem(id) {
|
||
const expires = shareDuration.value ? Number(shareDuration.value) : null;
|
||
const { response, data } = await fetchJsonOrThrow(`/api/jobs/${encodeURIComponent(id)}/shares`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ expires_in: expires }),
|
||
});
|
||
if (!response.ok || data.status !== "success") throw new Error(data?.error || "Share failed.");
|
||
const url = shareUrl(data.share.share_id);
|
||
await copyTextToClipboard(url);
|
||
await refreshAccountWorkspace();
|
||
setStatus(formatStatus(`${t("history_share_success")}: ${url}`));
|
||
}
|
||
|
||
async function loadHistoryItem(id) {
|
||
const { response, data } = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(id)}`);
|
||
if (!response.ok || data.status !== "success") throw new Error(data?.error || "History record not found.");
|
||
applyHistoryItem(data.item);
|
||
}
|
||
|
||
function applyHistoryItem(item) {
|
||
const payload = item.payload;
|
||
currentJobId = null;
|
||
workflowSelect.value = payload.workflow || "analysis";
|
||
modeSelect.value = payload.mode;
|
||
document.getElementById("material").value = payload.model.material;
|
||
document.getElementById("ensemble").value = payload.model.ensemble;
|
||
document.getElementById("celsius").value = payload.model.celsius;
|
||
document.getElementById("sodium").value = payload.model.sodium;
|
||
document.getElementById("magnesium").value = payload.model.magnesium;
|
||
document.getElementById("numSample").value = payload.options.num_sample;
|
||
document.getElementById("energyGap").value = payload.options.energy_gap;
|
||
document.getElementById("sparsityFraction").value = payload.options.sparsity_fraction;
|
||
document.getElementById("sparsityThreshold").value = payload.options.sparsity_threshold;
|
||
document.getElementById("resultLimit").value = payload.options.result_limit ?? 25;
|
||
document.getElementById("pairsPreviewSize").value = payload.options.pairs_preview_size ?? 24;
|
||
document.getElementById("tubeName").value = payload.tube?.name || "tube1";
|
||
document.getElementById("maxSize").value = payload.tube?.max_size || 2;
|
||
document.getElementById("designTrials").value = payload.design?.trials ?? 1;
|
||
document.getElementById("designStopCondition").value = payload.design?.stop_condition ?? 0.02;
|
||
document.getElementById("designSeed").value = payload.design?.seed ?? 0;
|
||
document.getElementById("designWobble").value = String(payload.design?.wobble_mutations ?? false);
|
||
document.getElementById("designMaxTimeHours").value = ((payload.design?.max_time_seconds ?? 0) / 3600);
|
||
document.getElementById("designFixedTargetPolicy").value = payload.design?.fixed_target_policy ?? "exclude_from_optimization";
|
||
const payloadCompute = Array.isArray(payload.compute) ? payload.compute : [];
|
||
document.querySelectorAll(".check input").forEach((input) => {
|
||
input.checked = payloadCompute.includes(input.value);
|
||
});
|
||
strandList.innerHTML = "";
|
||
payload.strands.forEach((strand) => addStrandRow(strand));
|
||
designDomainList.innerHTML = "";
|
||
(payload.design_domains || []).forEach((domain) => addDesignDomainRow(domain));
|
||
syncComplexRowsFromText(payload.complexes_text || "");
|
||
designComplexList.innerHTML = "";
|
||
(payload.design_complexes || payload.design_targets || []).forEach((target) => addDesignComplexRow(target));
|
||
if (!(payload.design_complexes || payload.design_targets)?.length) addDesignComplexRow({ strands: "A+B", structure: "(10+)10" });
|
||
designTubeList.innerHTML = "";
|
||
(payload.design_tubes || []).forEach((tube) => addDesignTubeRow(tube));
|
||
if (!(payload.design_tubes || []).length) {
|
||
addDesignTubeRow({
|
||
name: payload.tube?.name || "Tube 1",
|
||
max_size: payload.design?.off_target_max_size ?? payload.tube?.max_size ?? 2,
|
||
on_targets: [{ complex: "AB_target", concentration: 1.0, unit: "uM" }],
|
||
});
|
||
}
|
||
hardConstraintList.innerHTML = "";
|
||
(payload.hard_constraints || []).forEach((row) => addHardConstraintRow(row));
|
||
softConstraintList.innerHTML = "";
|
||
(payload.soft_constraints || []).forEach((row) => addSoftConstraintRow(row));
|
||
defectWeightList.innerHTML = "";
|
||
(payload.defect_weights || []).forEach((row) => addDefectWeightRow(row));
|
||
updateDesignReferenceLists();
|
||
syncWorkflow();
|
||
syncMode();
|
||
if (item.result) {
|
||
lastPayload = payload;
|
||
lastResult = item.result;
|
||
renderResult(item.result);
|
||
setStatus(formatStatus(t("status_done")));
|
||
} else if (item.error) {
|
||
lastPayload = payload;
|
||
lastResult = null;
|
||
const message = item.error.message || JSON.stringify(item.error);
|
||
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><div class="error-box">${escapeHtml(message)}</div></div>`;
|
||
setStatus(formatStatus(t("status_error")), true);
|
||
} else {
|
||
lastPayload = payload;
|
||
lastResult = null;
|
||
if (["queued", "running", "cancel_requested"].includes(item.status)) {
|
||
const label = item.status === "queued"
|
||
? t("status_queued")
|
||
: (item.status === "cancel_requested" ? t("status_canceling") : t("status_polling"));
|
||
results.innerHTML = `<div class="result-card"><h3>${escapeHtml(label)}</h3><p>${t("history_job_id")}: ${escapeHtml(item.job_id || item.id || "-")}</p></div>`;
|
||
setStatus(formatStatus(label));
|
||
} else {
|
||
renderEmptyState();
|
||
}
|
||
}
|
||
}
|
||
|
||
function sharedJobIsActive(item) {
|
||
return ["queued", "running", "cancel_requested"].includes(item?.status);
|
||
}
|
||
|
||
async function fetchSharedItem(shareId, { poll = false } = {}) {
|
||
const suffix = poll ? "?poll=1" : "";
|
||
const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}${suffix}`);
|
||
if (!response.ok || data.status !== "success") {
|
||
throw new Error(data?.error || `Share request failed with HTTP ${response.status}`);
|
||
}
|
||
const item = normalizeHistoryEntry(data.share, 0);
|
||
if (!item) throw new Error("Shared record is invalid.");
|
||
return item;
|
||
}
|
||
|
||
async function loadSharedHistoryFromUrl() {
|
||
const pathMatch = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{8,128})$/);
|
||
const shareId = pathMatch?.[1] || new URLSearchParams(window.location.search).get("share");
|
||
if (!shareId) return;
|
||
document.body.classList.add("share-mode");
|
||
try {
|
||
let item = await fetchSharedItem(shareId);
|
||
applyHistoryItem(item);
|
||
document.querySelectorAll(".control-panel input, .control-panel select, .control-panel textarea, .control-panel button").forEach((node) => {
|
||
if (!node.closest(".toolbar") && !node.matches("[data-design-tab]")) node.disabled = true;
|
||
});
|
||
setStatus(formatStatus(t("history_shared_loaded")));
|
||
while (sharedJobIsActive(item)) {
|
||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||
item = await fetchSharedItem(shareId, { poll: true });
|
||
applyHistoryItem(item);
|
||
}
|
||
} catch (error) {
|
||
setStatus(`${t("history_share_failed")}: ${error.message}`, true);
|
||
}
|
||
}
|
||
|
||
function renderHistory() {
|
||
if (!cloudHistory.length) {
|
||
historyList.innerHTML = `<div class="history-item"><p>${t("history_empty")}</p></div>`;
|
||
} else {
|
||
historyList.innerHTML = cloudHistory.map((item) => `
|
||
<div class="history-item" data-job-id="${escapeHtml(item.job_id)}">
|
||
<div class="history-meta"><strong>${escapeHtml(item.workflow)} / ${escapeHtml(item.mode)}</strong><span>${new Date(item.created_at * 1000).toLocaleString()}</span><span>${t("history_status")}: ${escapeHtml(item.status)}</span></div>
|
||
<div class="history-meta"><span>${escapeHtml(item.material)} · ${item.celsius} C · Na ${item.sodium} M · Mg ${item.magnesium} M</span><span>max size ${item.max_size} · ${item.strand_count} strands · ${item.complex_count} complexes</span><span>${t("history_elapsed")}: ${item.elapsed_seconds == null ? "-" : formatDuration(item.elapsed_seconds)}</span></div>
|
||
<p>${t("history_job_id")}: ${escapeHtml(item.job_id)} · ${(item.compute || []).map(escapeHtml).join(", ")}</p>
|
||
<div class="compact-actions">
|
||
<button class="secondary" data-history-load="${item.job_id}" type="button">${t("history_load")}</button>
|
||
<button class="secondary" data-history-export="${item.job_id}" type="button">${t("history_export_one")}</button>
|
||
<button class="secondary" data-history-share="${item.job_id}" type="button">${t("history_share")}</button>
|
||
${["queued", "running", "cancel_requested"].includes(item.status) ? "" : `<button class="secondary" data-history-delete="${item.job_id}" type="button">${t("history_delete")}</button>`}
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
const pages = Math.max(1, Math.ceil(cloudHistoryTotal / HISTORY_PAGE_SIZE));
|
||
const page = Math.floor(historyOffset / HISTORY_PAGE_SIZE) + 1;
|
||
historyPageInfo.textContent = t("history_page").replace("{page}", page).replace("{pages}", pages).replace("{total}", cloudHistoryTotal);
|
||
historyPrev.disabled = historyOffset === 0;
|
||
historyNext.disabled = historyOffset + HISTORY_PAGE_SIZE >= cloudHistoryTotal;
|
||
historyList.querySelectorAll("[data-history-load]").forEach((button) => {
|
||
button.addEventListener("click", () => loadHistoryItem(button.dataset.historyLoad).catch((error) => setStatus(error.message, true)));
|
||
});
|
||
historyList.querySelectorAll("[data-history-delete]").forEach((button) => {
|
||
button.addEventListener("click", () => deleteHistory(button.dataset.historyDelete).catch((error) => setStatus(error.message, true)));
|
||
});
|
||
historyList.querySelectorAll("[data-history-export]").forEach((button) => {
|
||
button.addEventListener("click", () => exportHistoryItem(button.dataset.historyExport).catch((error) => setStatus(error.message, true)));
|
||
});
|
||
historyList.querySelectorAll("[data-history-share]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
try {
|
||
await shareHistoryItem(button.dataset.historyShare);
|
||
} catch (error) {
|
||
setStatus(`${t("history_share_failed")}: ${error.message}`, true);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderShares() {
|
||
shareCount.textContent = String(cloudShares.length);
|
||
if (!cloudShares.length) {
|
||
shareList.innerHTML = `<div class="history-item"><p>${t("history_empty")}</p></div>`;
|
||
return;
|
||
}
|
||
const now = Date.now() / 1000;
|
||
shareList.innerHTML = cloudShares.map((share) => {
|
||
const expired = share.expires_at != null && share.expires_at <= now;
|
||
const enabled = Boolean(share.active) && !expired;
|
||
const expiry = share.expires_at == null ? t("share_permanent") : (expired ? t("share_expired") : new Date(share.expires_at * 1000).toLocaleString());
|
||
return `<div class="history-item share-row">
|
||
<div><div class="history-meta"><strong>${escapeHtml(share.workflow)} / ${escapeHtml(share.mode)}</strong><span>${enabled ? "active" : "inactive"}</span><span>${escapeHtml(expiry)}</span><span>${t("share_accesses")}: ${share.access_count || 0}</span></div><p>${escapeHtml(share.job_id)}</p><div class="compact-actions"><button class="secondary" data-share-copy="${share.share_id}" type="button">${t("share_copy")}</button><button class="secondary" data-share-toggle="${share.share_id}" data-active="${enabled ? "1" : "0"}" data-expired="${expired ? "1" : "0"}" type="button">${enabled ? t("share_disable") : t("share_enable")}</button></div></div>
|
||
<select data-share-expiry="${share.share_id}" aria-label="${t("share_duration")}"><option value="">${t("share_forever")}</option><option value="3600">${t("share_1h")}</option><option value="86400">${t("share_1d")}</option><option value="604800">${t("share_7d")}</option><option value="2592000">${t("share_30d")}</option></select>
|
||
</div>`;
|
||
}).join("");
|
||
shareList.querySelectorAll("[data-share-copy]").forEach((button) => button.addEventListener("click", async () => {
|
||
await copyTextToClipboard(shareUrl(button.dataset.shareCopy));
|
||
setStatus(formatStatus(t("history_share_copied")));
|
||
}));
|
||
shareList.querySelectorAll("[data-share-toggle]").forEach((button) => button.addEventListener("click", async () => {
|
||
const enabling = button.dataset.active !== "1";
|
||
const change = { active: enabling };
|
||
if (enabling && button.dataset.expired === "1") change.expires_in = null;
|
||
await updateShare(button.dataset.shareToggle, change);
|
||
}));
|
||
shareList.querySelectorAll("[data-share-expiry]").forEach((select) => select.addEventListener("change", async () => {
|
||
await updateShare(select.dataset.shareExpiry, { active: true, expires_in: select.value ? Number(select.value) : null });
|
||
}));
|
||
}
|
||
|
||
async function updateShare(id, change) {
|
||
const { response, data } = await fetchJsonOrThrow(`/api/account/shares/${encodeURIComponent(id)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(change),
|
||
});
|
||
if (!response.ok || data.status !== "success") throw new Error(data?.error || "Share update failed.");
|
||
await loadCloudShares();
|
||
}
|
||
|
||
function isBlank(value) {
|
||
return String(value ?? "").trim() === "";
|
||
}
|
||
|
||
function constraintError(prefixKey, index, messageKey) {
|
||
return `${t(prefixKey)} #${index}: ${t(messageKey)}`;
|
||
}
|
||
|
||
function validateHardConstraints(rows) {
|
||
for (const [offset, row] of rows.entries()) {
|
||
const index = offset + 1;
|
||
if (row.type === "match" || row.type === "complementarity") {
|
||
if (isBlank(row.left)) return constraintError("validation_hard_constraint", index, "validation_left_required");
|
||
if (isBlank(row.right)) return constraintError("validation_hard_constraint", index, "validation_right_required");
|
||
}
|
||
if (row.type === "similarity") {
|
||
if (isBlank(row.scope)) return constraintError("validation_hard_constraint", index, "validation_scope_required");
|
||
if (isBlank(row.reference)) return constraintError("validation_hard_constraint", index, "validation_reference_required");
|
||
}
|
||
if (row.type === "window") {
|
||
if (isBlank(row.scope)) return constraintError("validation_hard_constraint", index, "validation_scope_required");
|
||
if (isBlank(row.sources)) return constraintError("validation_hard_constraint", index, "validation_sources_required");
|
||
}
|
||
if (row.type === "library") {
|
||
if (isBlank(row.scope)) return constraintError("validation_hard_constraint", index, "validation_scope_required");
|
||
if (isBlank(row.catalog)) return constraintError("validation_hard_constraint", index, "validation_catalog_required");
|
||
}
|
||
if (row.type === "pattern" && isBlank(row.patterns)) {
|
||
return constraintError("validation_hard_constraint", index, "validation_patterns_required");
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function validateSoftConstraints(rows) {
|
||
for (const [offset, row] of rows.entries()) {
|
||
const index = offset + 1;
|
||
if (row.type === "pattern" && isBlank(row.patterns)) {
|
||
return constraintError("validation_soft_constraint", index, "validation_patterns_required");
|
||
}
|
||
if (row.type === "similarity") {
|
||
if (isBlank(row.scope)) return constraintError("validation_soft_constraint", index, "validation_scope_required");
|
||
if (isBlank(row.reference)) return constraintError("validation_soft_constraint", index, "validation_reference_required");
|
||
}
|
||
if (row.type === "energy_match" && isBlank(row.scope)) {
|
||
return constraintError("validation_soft_constraint", index, "validation_scope_required");
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function validatePayload(payload) {
|
||
if (!payload.strands.length) return t("validation_no_strands");
|
||
for (const strand of payload.strands) {
|
||
if (!strand.name) return t("validation_name");
|
||
if (!strand.sequence) return t("validation_sequence");
|
||
if (payload.workflow === "analysis" && (Number.isNaN(strand.concentration) || strand.concentration < 0)) return t("validation_concentration");
|
||
}
|
||
if (payload.workflow === "analysis") {
|
||
if (!payload.compute.length) return t("validation_compute");
|
||
if (payload.mode === "tube" && payload.tube.max_size < 1) return t("validation_max_size");
|
||
if (payload.mode === "complex" && !payload.complexes_text.trim()) return t("validation_complex_lines");
|
||
if (
|
||
payload.options.pairs_preview_size < 8
|
||
|| payload.options.pairs_preview_size > 128
|
||
) return t("validation_pairs_preview");
|
||
} else if (payload.workflow === "design") {
|
||
if (!payload.design_complexes.length) return t("validation_design_targets");
|
||
for (const target of payload.design_complexes) {
|
||
if (!target.strands) return t("validation_design_strands");
|
||
if (!target.structure) return t("validation_design_structure");
|
||
}
|
||
if (payload.mode === "tube") {
|
||
if (!payload.design_tubes.length) return t("validation_design_tubes");
|
||
for (const tube of payload.design_tubes) {
|
||
if (!tube.on_targets?.length) return t("validation_design_tubes");
|
||
}
|
||
}
|
||
const hardError = validateHardConstraints(payload.hard_constraints || []);
|
||
if (hardError) return hardError;
|
||
const softError = validateSoftConstraints(payload.soft_constraints || []);
|
||
if (softError) return softError;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildPayload() {
|
||
return {
|
||
workflow: workflowSelect.value,
|
||
mode: modeSelect.value,
|
||
model: {
|
||
material: document.getElementById("material").value,
|
||
ensemble: document.getElementById("ensemble").value,
|
||
celsius: Number(document.getElementById("celsius").value),
|
||
sodium: Number(document.getElementById("sodium").value),
|
||
magnesium: Number(document.getElementById("magnesium").value),
|
||
},
|
||
compute: Array.from(document.querySelectorAll('.check input:checked')).map((item) => item.value),
|
||
options: {
|
||
num_sample: Number(document.getElementById("numSample").value),
|
||
energy_gap: Number(document.getElementById("energyGap").value),
|
||
sparsity_fraction: Number(document.getElementById("sparsityFraction").value),
|
||
sparsity_threshold: Number(document.getElementById("sparsityThreshold").value),
|
||
single_mfe: false,
|
||
result_limit: Number(document.getElementById("resultLimit").value),
|
||
pairs_preview_size: Number(document.getElementById("pairsPreviewSize").value),
|
||
},
|
||
strands: getStrands(),
|
||
tube: {
|
||
name: document.getElementById("tubeName").value.trim(),
|
||
max_size: Number(document.getElementById("maxSize").value),
|
||
},
|
||
complexes_text: document.getElementById("complexesText").value,
|
||
design_domains: getDesignDomains(),
|
||
design_complexes: getDesignComplexes(),
|
||
design_tubes: getDesignTubes(),
|
||
design: {
|
||
trials: Number(document.getElementById("designTrials").value),
|
||
result_limit: Number(document.getElementById("resultLimit").value),
|
||
off_target_max_size: Number(getDesignTubes()[0]?.max_size ?? document.getElementById("maxSize").value ?? 2),
|
||
stop_condition: Number(document.getElementById("designStopCondition").value),
|
||
seed: Number(document.getElementById("designSeed").value),
|
||
wobble_mutations: document.getElementById("designWobble").value === "true",
|
||
max_time_seconds: Math.round(Number(document.getElementById("designMaxTimeHours").value || 0) * 3600),
|
||
fixed_target_policy: document.getElementById("designFixedTargetPolicy").value,
|
||
},
|
||
hard_constraints: getHardConstraints(),
|
||
soft_constraints: getSoftConstraints(),
|
||
defect_weights: getDefectWeights(),
|
||
};
|
||
}
|
||
|
||
function buildAnalysisOptionsFromControls() {
|
||
return {
|
||
num_sample: Number(document.getElementById("numSample").value),
|
||
energy_gap: Number(document.getElementById("energyGap").value),
|
||
sparsity_fraction: Number(document.getElementById("sparsityFraction").value),
|
||
sparsity_threshold: Number(document.getElementById("sparsityThreshold").value),
|
||
single_mfe: false,
|
||
result_limit: Number(document.getElementById("resultLimit").value),
|
||
pairs_preview_size: Number(document.getElementById("pairsPreviewSize").value),
|
||
};
|
||
}
|
||
|
||
function buildDesignedAnalysisPayload(result) {
|
||
if (!result || workflowKind(result) !== "design" || !Array.isArray(result.strands) || !result.strands.length) {
|
||
throw new Error(t("design_analysis_unavailable"));
|
||
}
|
||
const inputStrandsByName = new Map((lastPayload?.strands || []).map((strand) => [strand.name, strand]));
|
||
const strands = result.strands.map((strand) => {
|
||
const input = inputStrandsByName.get(strand.name) || {};
|
||
return {
|
||
name: strand.name,
|
||
sequence: strand.sequence,
|
||
concentration: Number(input.concentration ?? 1.0),
|
||
unit: input.unit || "uM",
|
||
};
|
||
});
|
||
const targetLines = (result.design?.targets || result.complexes || [])
|
||
.map((target) => Array.isArray(target.strand_names) ? target.strand_names.join("+") : "")
|
||
.filter(Boolean);
|
||
return {
|
||
workflow: "analysis",
|
||
mode: result.mode || "tube",
|
||
model: { ...result.model },
|
||
compute: ["pfunc", "mfe", "pairs"],
|
||
options: buildAnalysisOptionsFromControls(),
|
||
strands,
|
||
tube: {
|
||
name: result.tube?.name || result.design?.target_tubes?.[0]?.name || "designed_tube",
|
||
max_size: Number(result.tube?.max_size ?? result.design?.target_tubes?.[0]?.max_size ?? 2),
|
||
},
|
||
complexes_text: targetLines.join("\n"),
|
||
design_domains: [],
|
||
design_complexes: [],
|
||
design_tubes: [],
|
||
design: {},
|
||
hard_constraints: [],
|
||
soft_constraints: [],
|
||
defect_weights: [],
|
||
};
|
||
}
|
||
|
||
function loadDesignedAnalysisInput(result) {
|
||
const payload = buildDesignedAnalysisPayload(result);
|
||
workflowSelect.value = "analysis";
|
||
modeSelect.value = payload.mode;
|
||
document.getElementById("material").value = payload.model.material;
|
||
document.getElementById("ensemble").value = payload.model.ensemble;
|
||
document.getElementById("celsius").value = payload.model.celsius;
|
||
document.getElementById("sodium").value = payload.model.sodium;
|
||
document.getElementById("magnesium").value = payload.model.magnesium;
|
||
document.getElementById("tubeName").value = payload.tube.name;
|
||
document.getElementById("maxSize").value = payload.tube.max_size;
|
||
document.querySelectorAll(".check input").forEach((input) => {
|
||
input.checked = payload.compute.includes(input.value);
|
||
});
|
||
strandList.innerHTML = "";
|
||
payload.strands.forEach((strand) => addStrandRow(strand));
|
||
syncComplexRowsFromText(payload.complexes_text);
|
||
syncWorkflow();
|
||
syncMode();
|
||
document.querySelector(".main-grid")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
setStatus(formatStatus(t("design_analysis_loaded")));
|
||
return payload;
|
||
}
|
||
|
||
function renderModelCard(result) {
|
||
return `
|
||
<div class="result-card">
|
||
<div class="result-card-header">
|
||
<h3>${t("model_card")}</h3>
|
||
<div class="meta-line">
|
||
<span class="meta-chip">${result.model.material}</span>
|
||
<span class="meta-chip">${result.model.ensemble}</span>
|
||
</div>
|
||
</div>
|
||
<div class="kv">
|
||
<div><strong>${t("summary_mode")}</strong><span class="metric-value">${result.mode}</span></div>
|
||
<div><strong>${t("material_label")}</strong><span class="metric-value">${result.model.material}</span></div>
|
||
<div><strong>${t("ensemble_label")}</strong><span class="metric-value">${result.model.ensemble}</span></div>
|
||
<div><strong>${t("celsius_label")}</strong><span class="metric-value">${result.model.celsius}</span></div>
|
||
<div><strong>${t("summary_compute")}</strong><span class="metric-value">${result.compute.join(", ")}</span></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderSummaryCard(result) {
|
||
const isDesign = workflowKind(result) === "design";
|
||
const computeLabel = isDesign ? "design" : result.compute.join(", ");
|
||
return `
|
||
<div class="result-card">
|
||
<div class="result-card-header">
|
||
<h3>${t("summary_card")}</h3>
|
||
<div class="meta-line">
|
||
<span class="meta-chip">${result.mode}</span>
|
||
<span class="meta-chip">${result.complexes.length} ${t("count_complexes")}</span>
|
||
</div>
|
||
</div>
|
||
<div class="summary-bar">
|
||
<div><strong>${t("summary_mode")}</strong><br>${result.mode}</div>
|
||
<div><strong>${t("summary_complex_count")}</strong><br>${result.total_complex_count ?? result.complexes.length}</div>
|
||
<div><strong>${t("summary_displayed_count")}</strong><br>${result.displayed_complex_count ?? result.complexes.length}</div>
|
||
<div><strong>${t("summary_strands")}</strong><br>${result.strands.length}</div>
|
||
<div><strong>${t("summary_temperature")}</strong><br>${result.model.celsius} C</div>
|
||
<div><strong>${t("summary_compute")}</strong><br>${computeLabel}</div>
|
||
<div><strong>${t("summary_plot")}</strong><br>${isDesign ? "-" : t("plot_enabled")}</div>
|
||
${result.job_elapsed_seconds !== undefined ? `<div><strong>${t("summary_elapsed")}</strong><br>${result.job_elapsed_seconds.toFixed(3)} s</div>` : ""}
|
||
</div>
|
||
<div class="compact-actions" style="margin-top: 12px;">
|
||
<button class="secondary" id="exportInputBtn" type="button">${t("export_input")}</button>
|
||
<button class="secondary" id="exportResultBtn" type="button">${t("export_json")}</button>
|
||
${!isDesign ? `<button class="secondary" id="exportPairsCsvBtn" type="button">${t("export_pairs_csv")}</button>` : ""}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDesignDomainsCard(result) {
|
||
const domains = result.design?.domains || [];
|
||
if (!domains.length) return "";
|
||
const rows = domains.map((domain) => `
|
||
<tr>
|
||
<td>${escapeHtml(domain.name)}</td>
|
||
<td><span class="metric-value">${escapeHtml(domain.constraint || "-")}</span></td>
|
||
<td><span class="metric-value">${escapeHtml(domain.sequence || "-")}</span></td>
|
||
</tr>
|
||
`).join("");
|
||
return `
|
||
<div class="result-card">
|
||
<h3>${t("design_domains_card")}</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>${t("design_domain_name")}</th>
|
||
<th>${t("design_constraint")}</th>
|
||
<th>${t("design_sequence")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDesignStrandsCard(result) {
|
||
const rows = result.strands.map((strand) => `
|
||
<tr>
|
||
<td>${escapeHtml(strand.name)}</td>
|
||
<td><span class="metric-value">${escapeHtml(strand.definition || strand.constraint || "-")}</span></td>
|
||
<td><span class="metric-value">${escapeHtml(strand.constraint || "-")}</span></td>
|
||
<td><span class="metric-value">${escapeHtml(strand.sequence || "-")}</span></td>
|
||
</tr>
|
||
`).join("");
|
||
return `
|
||
<div class="result-card">
|
||
<h3>${t("design_strands_card")}</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>${t("strand_name")}</th>
|
||
<th>${t("design_definition")}</th>
|
||
<th>${t("design_constraint")}</th>
|
||
<th>${t("design_sequence")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDesignSummaryCard(result) {
|
||
const stats = result.design?.stats || {};
|
||
const excludedFixedCount = result.design?.optimization?.excluded_fixed_targets?.length || 0;
|
||
return `
|
||
<div class="result-card">
|
||
<h3>${t("design_summary_card")}</h3>
|
||
<div class="kv">
|
||
<div><strong>${t("design_ensemble_defect")}</strong><span class="metric-value">${Number(result.design?.ensemble_defect || 0).toFixed(6)}</span></div>
|
||
<div><strong>${t("design_trials_used")}</strong><span class="metric-value">${result.options?.trials ?? 1}</span></div>
|
||
<div><strong>${t("design_seed")}</strong><span class="metric-value">${stats.seed ?? "-"}</span></div>
|
||
<div><strong>${t("design_fixed_excluded")}</strong><span class="metric-value">${excludedFixedCount}</span></div>
|
||
<div><strong>${t("summary_elapsed")}</strong><span class="metric-value">${stats.design_time ? `${Number(stats.design_time).toFixed(3)} s` : "-"}</span></div>
|
||
</div>
|
||
<div class="compact-actions" style="margin-top: 12px;">
|
||
<button class="secondary" id="loadDesignedAnalysisBtn" type="button">${t("design_load_analysis")}</button>
|
||
<button id="runDesignedAnalysisBtn" type="button">${t("design_analyze_direct")}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDesignTargetsCard(result) {
|
||
const actualMap = new Map((result.design?.concentrations || []).map((row) => [row.complex_name, row]));
|
||
const rows = (result.design?.targets || []).map((target) => {
|
||
const actual = actualMap.get(target.name);
|
||
return `
|
||
<tr>
|
||
<td>${escapeHtml(target.name)}</td>
|
||
<td>${escapeHtml(target.strand_names.join(" + "))}</td>
|
||
<td><span class="metric-value">${escapeHtml(target.structure)}</span></td>
|
||
<td>${target.target_concentration_M !== undefined ? Number(target.target_concentration_M).toExponential(6) : "-"}</td>
|
||
<td>${actual ? Number(actual.concentration_M || actual.concentration || 0).toExponential(6) : "-"}</td>
|
||
</tr>
|
||
`;
|
||
}).join("");
|
||
return `
|
||
<div class="result-card full">
|
||
<h3>${t("design_targets_card")}</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>${t("design_target_name")}</th>
|
||
<th>${t("design_target_members")}</th>
|
||
<th>${t("design_target_structure_label")}</th>
|
||
<th>${t("design_target_target")}</th>
|
||
<th>${t("design_target_actual")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDesignDefectsCard(result) {
|
||
const defectRows = result.design?.defects?.tube_complexes || [];
|
||
const rows = defectRows.map((row) => `
|
||
<tr>
|
||
<td>${escapeHtml(row.tube_name || "-")}</td>
|
||
<td>${escapeHtml(row.complex_name || "-")}</td>
|
||
<td>${Number(row.structural || 0).toExponential(3)}</td>
|
||
<td>${Number(row.concentration || 0).toExponential(3)}</td>
|
||
<td>${Number(row.total || 0).toExponential(3)}</td>
|
||
</tr>
|
||
`).join("");
|
||
if (!rows) return "";
|
||
return `
|
||
<div class="result-card full">
|
||
<h3>${t("design_defects_card")}</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Tube</th>
|
||
<th>${t("table_complex")}</th>
|
||
<th>Structural</th>
|
||
<th>Concentration</th>
|
||
<th>Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderTubeCard(tube) {
|
||
if (!tube) return "";
|
||
const chartRows = tube.complex_concentrations.map((row, index) => ({ ...row, chart_index: row.chart_index ?? index }));
|
||
const rows = chartRows.map((row) => `
|
||
<tr data-tube-table-index="${row.chart_index}">
|
||
<td>${escapeHtml(row.display || row.complex_name || "-")}</td>
|
||
<td>${Number(row.concentration_M).toExponential(6)}</td>
|
||
${row.target_concentration_M !== undefined ? `<td>${Number(row.target_concentration_M).toExponential(6)}</td>` : ""}
|
||
</tr>
|
||
`).join("");
|
||
|
||
return `
|
||
<div class="result-card full">
|
||
<div class="result-card-header">
|
||
<h3>${t("tube_card")} · ${tube.name}</h3>
|
||
<div class="meta-line">
|
||
<span class="meta-chip">max_size ${tube.max_size}</span>
|
||
<span class="meta-chip">${chartRows.length} entries</span>
|
||
</div>
|
||
</div>
|
||
<div class="chart-shell">
|
||
<div class="chart-shell-top">
|
||
<strong>${t("tube_chart_title")}</strong>
|
||
<div class="compact-actions chart-switch">
|
||
<button class="secondary active" type="button" data-tube-chart-view="bar">${t("tube_view_bar")}</button>
|
||
<button class="secondary" type="button" data-tube-chart-view="pie">${t("tube_view_pie")}</button>
|
||
</div>
|
||
</div>
|
||
<p class="section-note">${t("tube_chart_caption")}</p>
|
||
<div class="chart-focus" id="tubeChartFocus">${t("tube_focus_idle")}</div>
|
||
<div data-tube-panel="bar">
|
||
<canvas class="chart-canvas" id="tube-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="chart-shell hidden" data-tube-panel="pie">
|
||
<strong>${t("tube_pie_title")}</strong>
|
||
<p class="section-note">${t("tube_pie_caption")}</p>
|
||
<div class="pie-shell">
|
||
<canvas id="tube-pie-chart"></canvas>
|
||
<div class="pie-legend-list" id="tube-pie-legend"></div>
|
||
</div>
|
||
</div>
|
||
<details class="table-collapse">
|
||
<summary>${t("tube_table_title")}</summary>
|
||
<div class="table-collapse-body">
|
||
<p class="table-collapse-note">${t("tube_table_hint")}</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>${t("table_complex")}</th>
|
||
<th>${t("table_concentration")}</th>
|
||
${chartRows.some((row) => row.target_concentration_M !== undefined) ? `<th>${t("table_target_concentration")}</th>` : ""}
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function setTubeChartFocus(text) {
|
||
const node = document.getElementById("tubeChartFocus");
|
||
if (node) node.textContent = text || t("tube_focus_idle");
|
||
}
|
||
|
||
function scrollToComplexCard(index, label = "") {
|
||
const card = document.querySelector(`[data-complex-card="${index}"]`);
|
||
if (!card) return;
|
||
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
card.animate(
|
||
[
|
||
{ boxShadow: "0 0 0 rgba(28, 124, 84, 0)" },
|
||
{ boxShadow: "0 0 0 4px rgba(28, 124, 84, 0.22)" },
|
||
{ boxShadow: "0 0 0 rgba(28, 124, 84, 0)" },
|
||
],
|
||
{ duration: 1400, easing: "ease-out" }
|
||
);
|
||
if (label) setTubeChartFocus(`${t("tube_focus_prefix")}: ${label}`);
|
||
}
|
||
|
||
function formatConcentrationForChart(valueM, unit, precision = 3) {
|
||
const scale = { M: 1, mM: 1e3, uM: 1e6, nM: 1e9, pM: 1e12 }[unit] || 1;
|
||
const value = valueM * scale;
|
||
const digits = value >= 100 ? 0 : value >= 10 ? 1 : value >= 1 ? 2 : precision;
|
||
return `${value.toFixed(digits)} ${unit}`;
|
||
}
|
||
|
||
function selectChartUnit(rows) {
|
||
const maxValue = Math.max(...rows.map((row) => Number(row.concentration_M) || 0), 0);
|
||
if (maxValue >= 1e-3) return "mM";
|
||
if (maxValue >= 1e-6) return "uM";
|
||
if (maxValue >= 1e-9) return "nM";
|
||
if (maxValue >= 1e-12) return "pM";
|
||
return "M";
|
||
}
|
||
|
||
function drawTubeChart(canvas, rows) {
|
||
if (!canvas || !rows?.length) return;
|
||
|
||
const labelUnit = selectChartUnit(rows);
|
||
const maxValue = Math.max(...rows.map((row) => Number(row.concentration_M) || 0), 0);
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const cssWidth = Math.max(760, canvas.clientWidth || 760);
|
||
const cssHeight = Math.max(260, 72 + rows.length * 34);
|
||
canvas.width = Math.round(cssWidth * dpr);
|
||
canvas.height = Math.round(cssHeight * dpr);
|
||
canvas.style.height = `${cssHeight}px`;
|
||
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||
|
||
const leftPad = 180;
|
||
const rightPad = 92;
|
||
const topPad = 28;
|
||
const bottomPad = 42;
|
||
const chartWidth = cssWidth - leftPad - rightPad;
|
||
const rowGap = 34;
|
||
const barHeight = 20;
|
||
const axisY = cssHeight - bottomPad;
|
||
const tickCount = 5;
|
||
const axisColor = cssVar("--chart-axis", "#15201b");
|
||
const gridColor = cssVar("--chart-grid", "rgba(21, 32, 27, 0.16)");
|
||
const labelColor = cssVar("--chart-label", "#58645c");
|
||
const barColor = cssVar("--chart-bar", "#cf4b30");
|
||
const badgeColor = cssVar("--chart-bar-soft", "#f1b49f");
|
||
const strongTextColor = cssVar("--chart-text-strong", "#ffffff");
|
||
const nameColor = cssVar("--chart-name", "#2540a8");
|
||
|
||
ctx.strokeStyle = gridColor;
|
||
ctx.fillStyle = axisColor;
|
||
ctx.lineWidth = 1;
|
||
ctx.font = '12px "IBM Plex Sans", "PingFang SC", sans-serif';
|
||
|
||
for (let tick = 0; tick <= tickCount; tick += 1) {
|
||
const ratio = tick / tickCount;
|
||
const x = leftPad + ratio * chartWidth;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, topPad - 6);
|
||
ctx.lineTo(x, axisY);
|
||
ctx.stroke();
|
||
ctx.fillStyle = labelColor;
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(formatConcentrationForChart(maxValue * ratio, labelUnit, 2), x, cssHeight - 16);
|
||
}
|
||
|
||
ctx.strokeStyle = axisColor;
|
||
ctx.lineWidth = 1.4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(leftPad, topPad - 10);
|
||
ctx.lineTo(leftPad, axisY);
|
||
ctx.lineTo(cssWidth - rightPad + 12, axisY);
|
||
ctx.stroke();
|
||
|
||
const hitboxes = [];
|
||
rows.forEach((row, index) => {
|
||
const y = topPad + index * rowGap;
|
||
const barWidth = maxValue > 0 ? (row.concentration_M / maxValue) * chartWidth : 0;
|
||
const label = formatConcentrationForChart(row.concentration_M, labelUnit, 2);
|
||
const badgeWidth = Math.max(48, ctx.measureText(label).width + 16);
|
||
const badgeX = Math.min(leftPad + barWidth + 8, cssWidth - rightPad + 10);
|
||
hitboxes.push({
|
||
index,
|
||
label: row.display || row.complex_name || "",
|
||
x: 6,
|
||
y: y - 7,
|
||
width: cssWidth - 12,
|
||
height: barHeight + 16,
|
||
});
|
||
|
||
ctx.fillStyle = nameColor;
|
||
ctx.textAlign = "left";
|
||
ctx.fillText(row.display, 12, y + 14);
|
||
|
||
ctx.fillStyle = barColor;
|
||
ctx.fillRect(leftPad, y, Math.max(barWidth, 2), barHeight);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(badgeX, y + barHeight / 2);
|
||
ctx.lineTo(badgeX + 8, y + barHeight - 1);
|
||
ctx.lineTo(badgeX + 8, y + 1);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = badgeColor;
|
||
ctx.fillRect(badgeX + 8, y, badgeWidth, barHeight);
|
||
ctx.fillStyle = strongTextColor;
|
||
ctx.font = 'bold 12px "IBM Plex Sans", "PingFang SC", sans-serif';
|
||
ctx.fillText(label, badgeX + 16, y + 14);
|
||
ctx.font = '12px "IBM Plex Sans", "PingFang SC", sans-serif';
|
||
});
|
||
|
||
canvas._tubeHitboxes = hitboxes;
|
||
if (!canvas.dataset.boundClick) {
|
||
canvas.addEventListener("mousemove", (event) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = event.clientX - rect.left;
|
||
const y = event.clientY - rect.top;
|
||
const match = (canvas._tubeHitboxes || []).find((box) => (
|
||
x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height
|
||
));
|
||
canvas.style.cursor = match ? "pointer" : "";
|
||
if (match) setTubeChartFocus(`${t("tube_focus_prefix")}: ${match.label}`);
|
||
});
|
||
canvas.addEventListener("mouseleave", () => {
|
||
canvas.style.cursor = "";
|
||
setTubeChartFocus();
|
||
});
|
||
canvas.addEventListener("click", (event) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = event.clientX - rect.left;
|
||
const y = event.clientY - rect.top;
|
||
const match = (canvas._tubeHitboxes || []).find((box) => (
|
||
x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height
|
||
));
|
||
if (match) {
|
||
scrollToComplexCard(match.index, match.label);
|
||
}
|
||
});
|
||
canvas.dataset.boundClick = "1";
|
||
}
|
||
}
|
||
|
||
function drawTubePieChart(canvas, legendEl, rows) {
|
||
if (!canvas || !legendEl || !rows?.length) return;
|
||
const palette = ["#1c7c54", "#2f9d6a", "#6ac57d", "#93d77f", "#f4c06b", "#e88f5a", "#d95d4e", "#8ba4d6"];
|
||
const topLimit = 7;
|
||
const baseRows = rows.slice(0, topLimit).map((row, idx) => ({
|
||
name: row.display,
|
||
concentration_M: Number(row.concentration_M) || 0,
|
||
color: palette[idx % palette.length],
|
||
}));
|
||
const otherSum = rows.slice(topLimit).reduce((acc, row) => acc + (Number(row.concentration_M) || 0), 0);
|
||
if (otherSum > 0) {
|
||
baseRows.push({
|
||
name: t("others_label"),
|
||
concentration_M: otherSum,
|
||
color: "#9aa5a1",
|
||
});
|
||
}
|
||
const total = baseRows.reduce((acc, row) => acc + row.concentration_M, 0);
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const size = 280;
|
||
canvas.width = Math.round(size * dpr);
|
||
canvas.height = Math.round(size * dpr);
|
||
canvas.style.width = `${size}px`;
|
||
canvas.style.height = `${size}px`;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, size, size);
|
||
const themePanel = cssVar("--panel-strong", "#fff");
|
||
const themeInk = cssVar("--ink", "#111");
|
||
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = 102;
|
||
const innerRadius = 52;
|
||
let start = -Math.PI / 2;
|
||
const slices = [];
|
||
baseRows.forEach((row, index) => {
|
||
const ratio = total > 0 ? row.concentration_M / total : 0;
|
||
const delta = ratio * Math.PI * 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy);
|
||
ctx.arc(cx, cy, radius, start, start + delta);
|
||
ctx.closePath();
|
||
ctx.fillStyle = row.color;
|
||
ctx.fill();
|
||
slices.push({ index, start, end: start + delta, label: row.name });
|
||
start += delta;
|
||
});
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
|
||
ctx.fillStyle = themePanel;
|
||
ctx.fill();
|
||
ctx.fillStyle = themeInk;
|
||
ctx.font = '600 12px "IBM Plex Sans", "PingFang SC", sans-serif';
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(`${rows.length}`, cx, cy - 4);
|
||
ctx.font = '500 11px "IBM Plex Sans", "PingFang SC", sans-serif';
|
||
ctx.fillText(t("count_complexes"), cx, cy + 14);
|
||
|
||
legendEl.innerHTML = baseRows.map((row, index) => {
|
||
const ratio = total > 0 ? (row.concentration_M / total) * 100 : 0;
|
||
return `
|
||
<div class="pie-legend-item" data-pie-index="${index}">
|
||
<span class="pie-dot" style="background:${row.color}"></span>
|
||
<span>${escapeHtml(row.name)}</span>
|
||
<span>${ratio.toFixed(1)}%</span>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
|
||
const findRowIndex = (name) => rows.findIndex((row) => (row.display || row.complex_name) === name);
|
||
legendEl.querySelectorAll("[data-pie-index]").forEach((item) => {
|
||
item.addEventListener("mouseenter", () => {
|
||
setTubeChartFocus(`${t("tube_focus_prefix")}: ${baseRows[Number(item.dataset.pieIndex)]?.name || ""}`);
|
||
item.classList.add("active");
|
||
});
|
||
item.addEventListener("mouseleave", () => {
|
||
item.classList.remove("active");
|
||
setTubeChartFocus();
|
||
});
|
||
item.addEventListener("click", () => {
|
||
const slice = baseRows[Number(item.dataset.pieIndex)];
|
||
const rowIndex = findRowIndex(slice.name);
|
||
if (rowIndex >= 0) scrollToComplexCard(rowIndex, slice.name);
|
||
});
|
||
});
|
||
|
||
canvas.addEventListener("mousemove", (event) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = event.clientX - rect.left - cx;
|
||
const y = event.clientY - rect.top - cy;
|
||
const distance = Math.hypot(x, y);
|
||
if (distance < innerRadius || distance > radius) {
|
||
canvas.style.cursor = "";
|
||
setTubeChartFocus();
|
||
return;
|
||
}
|
||
let angle = Math.atan2(y, x);
|
||
if (angle < -Math.PI / 2) angle += Math.PI * 2;
|
||
const match = slices.find((slice) => angle >= slice.start && angle <= slice.end);
|
||
canvas.style.cursor = match ? "pointer" : "";
|
||
if (match) setTubeChartFocus(`${t("tube_focus_prefix")}: ${match.label}`);
|
||
}, { once: false });
|
||
canvas.addEventListener("mouseleave", () => {
|
||
canvas.style.cursor = "";
|
||
setTubeChartFocus();
|
||
}, { once: false });
|
||
canvas.addEventListener("click", (event) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = event.clientX - rect.left - cx;
|
||
const y = event.clientY - rect.top - cy;
|
||
const distance = Math.hypot(x, y);
|
||
if (distance < innerRadius || distance > radius) return;
|
||
let angle = Math.atan2(y, x);
|
||
if (angle < -Math.PI / 2) angle += Math.PI * 2;
|
||
const match = slices.find((slice) => angle >= slice.start && angle <= slice.end);
|
||
if (!match) return;
|
||
const rowIndex = findRowIndex(match.label);
|
||
if (rowIndex >= 0) scrollToComplexCard(rowIndex, match.label);
|
||
}, { once: false });
|
||
}
|
||
|
||
function drawHeatmap(canvas, matrixPreview) {
|
||
if (!canvas || !matrixPreview?.preview) return;
|
||
const data = matrixPreview.preview;
|
||
const n = data.length || 1;
|
||
const size = 320;
|
||
const cell = size / n;
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const ctx = canvas.getContext("2d");
|
||
|
||
for (let i = 0; i < n; i += 1) {
|
||
for (let j = 0; j < n; j += 1) {
|
||
const v = Math.max(0, Math.min(1, Number(data[i][j] || 0)));
|
||
ctx.fillStyle = blendColor(heatmapColors.low, heatmapColors.high, v);
|
||
ctx.fillRect(j * cell, i * cell, cell, cell);
|
||
}
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
function buildPreviewPayload(value, depth = 0) {
|
||
if (depth > 4) return "[truncated]";
|
||
if (Array.isArray(value)) {
|
||
return value.slice(0, 12).map((item) => buildPreviewPayload(item, depth + 1));
|
||
}
|
||
if (!value || typeof value !== "object") return value;
|
||
|
||
const output = {};
|
||
Object.entries(value).forEach(([key, item]) => {
|
||
if (key === "rnaplot_svg") {
|
||
output[key] = "[embedded SVG omitted from preview]";
|
||
return;
|
||
}
|
||
if (key === "traceback") {
|
||
output[key] = "[traceback omitted from preview]";
|
||
return;
|
||
}
|
||
output[key] = buildPreviewPayload(item, depth + 1);
|
||
});
|
||
return output;
|
||
}
|
||
|
||
function syncViewerScale() {
|
||
const svg = viewerBody.querySelector("svg");
|
||
if (!svg) return;
|
||
svg.style.transform = `translate(${viewerPanX}px, ${viewerPanY}px) scale(${viewerScale})`;
|
||
}
|
||
|
||
function openViewer(svgMarkup) {
|
||
if (!svgMarkup) return;
|
||
viewerScale = 1;
|
||
viewerPanX = 0;
|
||
viewerPanY = 0;
|
||
viewerDragState = null;
|
||
viewerBody.innerHTML = `<div class="viewer-stage">${svgMarkup}</div>`;
|
||
syncViewerScale();
|
||
viewer.classList.remove("hidden");
|
||
document.body.style.overflow = "hidden";
|
||
}
|
||
|
||
function closeViewer() {
|
||
viewer.classList.add("hidden");
|
||
viewerBody.innerHTML = "";
|
||
document.body.style.overflow = "";
|
||
viewerScale = 1;
|
||
viewerPanX = 0;
|
||
viewerPanY = 0;
|
||
viewerDragState = null;
|
||
}
|
||
|
||
function getRenderedMfeSvgMarkup(index) {
|
||
const node = document.querySelector(`[data-mfe-svg-wrap="${index}"]`);
|
||
return node?.innerHTML?.trim() || "";
|
||
}
|
||
|
||
function baseColor(baseChar) {
|
||
const key = String(baseChar || "").toUpperCase();
|
||
return {
|
||
A: "#ff6b6b",
|
||
T: "#4ecdc4",
|
||
U: "#4ecdc4",
|
||
G: "#f6bd60",
|
||
C: "#6abf69",
|
||
}[key] || "#2a2f2d";
|
||
}
|
||
|
||
function beautifyRnaSvg(rawSvg, annotations = null) {
|
||
if (!rawSvg) return "";
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(rawSvg, "image/svg+xml");
|
||
const svg = doc.documentElement;
|
||
if (!svg || svg.nodeName.toLowerCase() !== "svg") return rawSvg;
|
||
|
||
svg.querySelectorAll("*").forEach((node) => {
|
||
Array.from(node.attributes || []).forEach((attr) => {
|
||
if (/^on/i.test(attr.name)) {
|
||
node.removeAttribute(attr.name);
|
||
}
|
||
});
|
||
});
|
||
|
||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||
const styleId = "np-style-block";
|
||
if (!svg.querySelector(`#${styleId}`)) {
|
||
const styleTag = doc.createElementNS("http://www.w3.org/2000/svg", "style");
|
||
styleTag.setAttribute("id", styleId);
|
||
styleTag.textContent = `
|
||
.backbone { stroke: #2f3a36 !important; stroke-width: 1.55 !important; opacity: 0.9; }
|
||
.basepairs { stroke: #2f72b8 !important; stroke-width: 2.2 !important; stroke-linecap: round; opacity: 0.95; }
|
||
#seq text {
|
||
font-family: "IBM Plex Sans","Noto Sans SC",sans-serif !important;
|
||
font-size: ${graphFontConfig().mfe}px !important;
|
||
font-weight: 700 !important;
|
||
stroke: rgba(255,255,255,0.65);
|
||
stroke-width: 0.9px;
|
||
paint-order: stroke fill;
|
||
}
|
||
`;
|
||
svg.insertBefore(styleTag, svg.firstChild);
|
||
}
|
||
|
||
const seqGroup = svg.querySelector("#seq");
|
||
if (!seqGroup) return new XMLSerializer().serializeToString(svg);
|
||
const textNodes = Array.from(seqGroup.querySelectorAll("text"));
|
||
textNodes.forEach((textNode) => {
|
||
textNode.setAttribute("fill", baseColor(textNode.textContent));
|
||
});
|
||
|
||
if (annotations?.residue_probabilities?.length) {
|
||
annotations.residue_probabilities.forEach((probability, index) => {
|
||
const textNode = textNodes[index];
|
||
if (!textNode) return;
|
||
textNode.setAttribute("fill", probabilityColorForValue(probability));
|
||
textNode.setAttribute("stroke", probabilityTextStroke(probability));
|
||
textNode.setAttribute("stroke-width", "1.15");
|
||
});
|
||
svg.querySelectorAll(".basepairs").forEach((line) => {
|
||
line.setAttribute("stroke", "#64707a");
|
||
line.setAttribute("stroke-opacity", "0.88");
|
||
});
|
||
}
|
||
return new XMLSerializer().serializeToString(svg);
|
||
}
|
||
|
||
function buildProbabilitySvg(rawSvg, annotations) {
|
||
if (!rawSvg || !annotations?.residue_probabilities?.length) return "";
|
||
return beautifyRnaSvg(rawSvg, annotations);
|
||
}
|
||
|
||
function getStyledMfeSvg(index, mfe) {
|
||
if (!mfe?.rnaplot_svg) return "";
|
||
const cacheKey = `mfe:${index}`;
|
||
if (styledSvgCache.has(cacheKey)) return styledSvgCache.get(cacheKey);
|
||
const svg = beautifyRnaSvg(mfe.rnaplot_svg);
|
||
styledSvgCache.set(cacheKey, svg);
|
||
return svg;
|
||
}
|
||
|
||
function getProbabilitySvg(index, mfe) {
|
||
if (!mfe?.rnaplot_svg || !mfe?.pair_probability_annotations) return "";
|
||
const cacheKey = `prob:${index}`;
|
||
if (probabilitySvgCache.has(cacheKey)) return probabilitySvgCache.get(cacheKey);
|
||
const svg = buildProbabilitySvg(mfe.rnaplot_svg, mfe.pair_probability_annotations);
|
||
probabilitySvgCache.set(cacheKey, svg);
|
||
return svg;
|
||
}
|
||
|
||
function getActiveStructureSvg(index) {
|
||
const activeView = document.querySelector(`input[name="view-${index}"]:checked`)?.value || "mfe";
|
||
const mfe = lastResult?.complexes?.[index]?.mfe?.[0];
|
||
if (activeView === "pairs") return getProbabilitySvg(index, mfe);
|
||
return getRenderedMfeSvgMarkup(index) || getStyledMfeSvg(index, mfe);
|
||
}
|
||
|
||
function parseStructurePairs(structure) {
|
||
const pairs = [];
|
||
const stack = [];
|
||
let compactLength = 0;
|
||
for (const char of String(structure || "")) {
|
||
if (char === "+") continue;
|
||
if (char === "(") {
|
||
stack.push(compactLength);
|
||
} else if (char === ")" && stack.length) {
|
||
pairs.push([stack.pop(), compactLength]);
|
||
}
|
||
compactLength += 1;
|
||
}
|
||
return { pairs, compactLength };
|
||
}
|
||
|
||
function buildBackboneLinks(strandLengths, totalLength) {
|
||
const links = [];
|
||
if (!Array.isArray(strandLengths) || !strandLengths.length) {
|
||
for (let i = 0; i < totalLength - 1; i += 1) {
|
||
links.push({ source: i, target: i + 1, type: "backbone", weight: 1 });
|
||
}
|
||
return links;
|
||
}
|
||
let cursor = 0;
|
||
strandLengths.forEach((length) => {
|
||
const len = Math.max(0, Number(length) || 0);
|
||
for (let i = 0; i < len - 1; i += 1) {
|
||
links.push({ source: cursor + i, target: cursor + i + 1, type: "backbone", weight: 1 });
|
||
}
|
||
cursor += len;
|
||
});
|
||
return links;
|
||
}
|
||
|
||
function buildResidueMetadata(strandLengths, strandNames, totalLength) {
|
||
const metadata = Array.from({ length: totalLength }, (_, index) => ({
|
||
strandIndex: 0,
|
||
strandLabel: strandNames?.[0] || "strand 1",
|
||
localIndex: index + 1,
|
||
}));
|
||
if (!Array.isArray(strandLengths) || !strandLengths.length) return metadata;
|
||
|
||
const duplicateCounter = new Map();
|
||
let cursor = 0;
|
||
strandLengths.forEach((rawLength, strandIndex) => {
|
||
const length = Math.max(0, Number(rawLength) || 0);
|
||
const rawName = strandNames?.[strandIndex] || `strand ${strandIndex + 1}`;
|
||
const seenCount = (duplicateCounter.get(rawName) || 0) + 1;
|
||
duplicateCounter.set(rawName, seenCount);
|
||
const strandLabel = seenCount > 1 ? `${rawName} #${seenCount}` : rawName;
|
||
for (let offset = 0; offset < length && cursor + offset < totalLength; offset += 1) {
|
||
metadata[cursor + offset] = {
|
||
strandIndex,
|
||
strandLabel,
|
||
localIndex: offset + 1,
|
||
};
|
||
}
|
||
cursor += length;
|
||
});
|
||
return metadata;
|
||
}
|
||
|
||
function parseCoordsFromSvg(rawSvg, expectedCount) {
|
||
if (!rawSvg) return null;
|
||
const match = String(rawSvg).match(/const coords = \[(.*?)\];/s) || String(rawSvg).match(/let coords = \[(.*?)\];/s);
|
||
if (!match) return null;
|
||
const coords = [];
|
||
const pattern = /x:\s*([-+]?\d*\.?\d+)\s*,\s*y:\s*([-+]?\d*\.?\d+)/g;
|
||
let item;
|
||
while ((item = pattern.exec(match[1])) !== null) {
|
||
coords.push({ x: Number(item[1]), y: Number(item[2]) });
|
||
}
|
||
if (!coords.length) return null;
|
||
if (expectedCount && coords.length !== expectedCount) return null;
|
||
return coords;
|
||
}
|
||
|
||
function buildAnchoredCoordinates(rawSvg, expectedCount, width, height, padding = 56) {
|
||
const coords = parseCoordsFromSvg(rawSvg, expectedCount);
|
||
if (!coords?.length) return null;
|
||
const xs = coords.map((point) => point.x);
|
||
const ys = coords.map((point) => point.y);
|
||
const minX = Math.min(...xs);
|
||
const maxX = Math.max(...xs);
|
||
const minY = Math.min(...ys);
|
||
const maxY = Math.max(...ys);
|
||
const spanX = Math.max(1, maxX - minX);
|
||
const spanY = Math.max(1, maxY - minY);
|
||
const scale = Math.min((width - padding * 2) / spanX, (height - padding * 2) / spanY);
|
||
const offsetX = (width - spanX * scale) / 2;
|
||
const offsetY = (height - spanY * scale) / 2;
|
||
return coords.map((point) => ({
|
||
x: (point.x - minX) * scale + offsetX,
|
||
y: (point.y - minY) * scale + offsetY,
|
||
}));
|
||
}
|
||
|
||
function estimateForceHeight(nodeCount, strandCount, anchoredCoords) {
|
||
if (anchoredCoords?.length) {
|
||
const xs = anchoredCoords.map((point) => point.x);
|
||
const ys = anchoredCoords.map((point) => point.y);
|
||
const spanX = Math.max(1, Math.max(...xs) - Math.min(...xs));
|
||
const spanY = Math.max(1, Math.max(...ys) - Math.min(...ys));
|
||
const aspectBoost = spanY / spanX;
|
||
return Math.max(440, Math.min(920, Math.round(420 + Math.sqrt(nodeCount) * 18 + aspectBoost * 120)));
|
||
}
|
||
return Math.max(440, Math.min(920, Math.round(420 + Math.sqrt(nodeCount) * 20 + strandCount * 28)));
|
||
}
|
||
|
||
function fallbackAnchoredCoordinates(nodeCount, strandLengths, width, height) {
|
||
const coords = Array.from({ length: nodeCount }, () => ({ x: width / 2, y: height / 2 }));
|
||
if (!nodeCount) return coords;
|
||
const strands = Array.isArray(strandLengths) && strandLengths.length ? strandLengths : [nodeCount];
|
||
const spacingY = height / (strands.length + 1);
|
||
let cursor = 0;
|
||
strands.forEach((rawLength, strandIndex) => {
|
||
const length = Math.max(1, Number(rawLength) || 0);
|
||
const usableWidth = Math.max(180, width - 140);
|
||
const spacingX = usableWidth / Math.max(1, length - 1);
|
||
for (let offset = 0; offset < length && cursor + offset < nodeCount; offset += 1) {
|
||
coords[cursor + offset] = {
|
||
x: 70 + spacingX * offset,
|
||
y: spacingY * (strandIndex + 1),
|
||
};
|
||
}
|
||
cursor += length;
|
||
});
|
||
return coords;
|
||
}
|
||
|
||
function fitForceScene(d3, svg, zoom, width, height, nodes, padding = 42, duration = 180) {
|
||
const xs = nodes.map((node) => Number.isFinite(node.x) ? node.x : node.homeX);
|
||
const ys = nodes.map((node) => Number.isFinite(node.y) ? node.y : node.homeY);
|
||
const minX = Math.min(...xs) - padding;
|
||
const maxX = Math.max(...xs) + padding;
|
||
const minY = Math.min(...ys) - padding;
|
||
const maxY = Math.max(...ys) + padding;
|
||
const spanX = Math.max(1, maxX - minX);
|
||
const spanY = Math.max(1, maxY - minY);
|
||
const scale = Math.max(0.4, Math.min(3.2, 0.94 / Math.max(spanX / width, spanY / height)));
|
||
const translateX = width / 2 - scale * (minX + maxX) / 2;
|
||
const translateY = height / 2 - scale * (minY + maxY) / 2;
|
||
const transform = d3.zoomIdentity.translate(translateX, translateY).scale(scale);
|
||
const selection = duration > 0 ? svg.transition().duration(duration) : svg;
|
||
selection.call(zoom.transform, transform);
|
||
}
|
||
|
||
function renderForceInspector(target, node) {
|
||
if (!target) return;
|
||
if (!node) {
|
||
target.innerHTML = `
|
||
<div class="force-inspector-title">${t("force_selection_title")}</div>
|
||
<p class="force-note">${t("force_selection_empty")}</p>
|
||
`;
|
||
return;
|
||
}
|
||
target.innerHTML = `
|
||
<div class="force-inspector-title">${escapeHtml(node.label)}${node.id + 1}</div>
|
||
<div class="force-inspector-grid">
|
||
<div><strong>${t("force_selection_index")}</strong><span>${node.id + 1}</span></div>
|
||
<div><strong>${t("force_selection_strand")}</strong><span>${escapeHtml(node.strandLabel)}</span></div>
|
||
<div><strong>${t("force_selection_local")}</strong><span>${node.localIndex}</span></div>
|
||
<div><strong>${t("force_selection_partner")}</strong><span>${escapeHtml(node.partnerLabel)}</span></div>
|
||
<div><strong>${t("force_selection_probability")}</strong><span>${node.residueProbability}</span></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderForceView(container, complex, mfe) {
|
||
if (!container || container.dataset.rendered === "1") return;
|
||
if (!window.d3) {
|
||
container.innerHTML = `<div class="error-box">D3.js not loaded.</div>`;
|
||
container.dataset.rendered = "1";
|
||
return;
|
||
}
|
||
|
||
const { pairs, compactLength } = parseStructurePairs(mfe?.structure || "");
|
||
const sequence = String(complex.sequence || "").replaceAll("+", "");
|
||
const n = compactLength || sequence.length;
|
||
if (!n) {
|
||
container.innerHTML = `<div class="error-box">${t("request_failed")}</div>`;
|
||
container.dataset.rendered = "1";
|
||
return;
|
||
}
|
||
const strandLengths = complex.strand_lengths || [];
|
||
const strandNames = complex.strand_names || [];
|
||
const residueMetadata = buildResidueMetadata(strandLengths, strandNames, n);
|
||
const pairProbMap = new Map(
|
||
(mfe?.pair_probability_annotations?.pair_probabilities || []).flatMap((row) => ([
|
||
[`${row.i - 1},${row.j - 1}`, Number(row.probability) || 0],
|
||
[`${row.j - 1},${row.i - 1}`, Number(row.probability) || 0],
|
||
]))
|
||
);
|
||
const residueProbabilities = mfe?.pair_probability_annotations?.residue_probabilities || [];
|
||
const pairPartnerByNode = new Map(pairs.flatMap(([left, right]) => ([[left, right], [right, left]])));
|
||
const links = [
|
||
...buildBackboneLinks(strandLengths, n),
|
||
...pairs.map(([left, right]) => ({
|
||
source: left,
|
||
target: right,
|
||
type: "pair",
|
||
weight: pairProbMap.get(`${left},${right}`) ?? 0,
|
||
})),
|
||
];
|
||
|
||
const canvasWidth = Math.max(760, container.clientWidth ? container.clientWidth - 4 : 920);
|
||
const anchoredPreview = buildAnchoredCoordinates(mfe?.rnaplot_svg, n, canvasWidth, 720);
|
||
const height = estimateForceHeight(n, Math.max(1, strandLengths.length), anchoredPreview);
|
||
const anchoredCoords = anchoredPreview || fallbackAnchoredCoordinates(n, strandLengths, canvasWidth, height);
|
||
|
||
const nodes = Array.from({ length: n }, (_, idx) => {
|
||
const partnerIndex = pairPartnerByNode.get(idx);
|
||
const residueProbability = Number(residueProbabilities[idx]);
|
||
return {
|
||
id: idx,
|
||
label: sequence[idx] || `${idx + 1}`,
|
||
strandIndex: residueMetadata[idx]?.strandIndex ?? 0,
|
||
strandLabel: residueMetadata[idx]?.strandLabel ?? `strand 1`,
|
||
localIndex: residueMetadata[idx]?.localIndex ?? idx + 1,
|
||
partner: partnerIndex ?? null,
|
||
partnerLabel: partnerIndex == null
|
||
? t("force_unpaired")
|
||
: `${sequence[partnerIndex] || "?"}${partnerIndex + 1}`,
|
||
residueProbability: Number.isFinite(residueProbability) ? residueProbability.toFixed(3) : "n/a",
|
||
homeX: anchoredCoords[idx]?.x ?? canvasWidth / 2,
|
||
homeY: anchoredCoords[idx]?.y ?? height / 2,
|
||
x: anchoredCoords[idx]?.x ?? canvasWidth / 2,
|
||
y: anchoredCoords[idx]?.y ?? height / 2,
|
||
};
|
||
});
|
||
|
||
const distanceFromAnchors = (sourceIndex, targetIndex) => {
|
||
const source = anchoredCoords[sourceIndex];
|
||
const target = anchoredCoords[targetIndex];
|
||
if (!source || !target) return null;
|
||
return Math.hypot(source.x - target.x, source.y - target.y);
|
||
};
|
||
links.forEach((link) => {
|
||
const sourceIndex = typeof link.source === "number" ? link.source : link.source.id;
|
||
const targetIndex = typeof link.target === "number" ? link.target : link.target.id;
|
||
link.anchorDistance = distanceFromAnchors(sourceIndex, targetIndex);
|
||
});
|
||
|
||
container.innerHTML = `
|
||
<div class="force-shell">
|
||
<div class="force-toolbar">
|
||
<div class="force-toolbar-top">
|
||
<strong>${t("force_title")}</strong>
|
||
<span>${t("force_hint")}</span>
|
||
</div>
|
||
<div class="force-summary">
|
||
<span class="meta-chip">${n} ${t("force_meta_residues")}</span>
|
||
<span class="meta-chip">${pairs.length} ${t("force_meta_pairs")}</span>
|
||
<span class="meta-chip">${Math.max(1, strandLengths.length || complex.strand_names?.length || 1)} ${t("force_meta_strands")}</span>
|
||
</div>
|
||
<div class="force-controls">
|
||
<button class="secondary" type="button" data-force-fit>${t("force_fit")}</button>
|
||
<button class="secondary" type="button" data-force-reset>${t("force_reset")}</button>
|
||
<button class="secondary" type="button" data-force-relax>${t("force_relax")}</button>
|
||
<button class="secondary" type="button" data-force-labels>${n <= 140 ? t("force_labels_on") : t("force_labels_off")}</button>
|
||
</div>
|
||
</div>
|
||
<div class="force-canvas" data-force-canvas style="height:${height}px;"></div>
|
||
<div class="force-footer">
|
||
<div class="force-inspector" data-force-inspector></div>
|
||
<div class="force-legend">
|
||
<div class="force-legend-row"><span class="force-legend-line"></span><span>${t("force_legend_backbone")}</span></div>
|
||
<div class="force-legend-row"><span class="force-legend-line pair"></span><span>${t("force_legend_pair")}</span></div>
|
||
<div class="force-legend-row"><span class="force-legend-line anchor"></span><span>${t("force_legend_anchor")}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const canvasHost = container.querySelector("[data-force-canvas]");
|
||
const inspector = container.querySelector("[data-force-inspector]");
|
||
renderForceInspector(inspector, null);
|
||
const width = Math.max(760, canvasHost.clientWidth || canvasWidth);
|
||
const d3 = window.d3;
|
||
const svg = d3.select(canvasHost).append("svg")
|
||
.attr("width", width)
|
||
.attr("height", height)
|
||
.attr("viewBox", `0 0 ${width} ${height}`)
|
||
.style("display", "block")
|
||
.style("width", "100%")
|
||
.style("height", `${height}px`);
|
||
const root = svg.append("g");
|
||
const guide = root.append("g")
|
||
.attr("stroke-linecap", "round")
|
||
.attr("stroke-dasharray", "5 6")
|
||
.attr("stroke", "rgba(28,124,84,0.32)")
|
||
.attr("stroke-width", 1.2)
|
||
.attr("stroke-opacity", 0.55);
|
||
|
||
const zoom = d3.zoom().scaleExtent([0.35, 4]).on("zoom", (event) => {
|
||
root.attr("transform", event.transform);
|
||
});
|
||
svg.call(zoom);
|
||
|
||
const link = root.append("g")
|
||
.attr("stroke-linecap", "round")
|
||
.selectAll("line")
|
||
.data(links)
|
||
.join("line")
|
||
.attr("stroke", (d) => (d.type === "pair" ? colorForProbability(d.weight) : "#5c6b65"))
|
||
.attr("stroke-opacity", (d) => (d.type === "pair" ? 0.92 : 0.68))
|
||
.attr("stroke-width", (d) => (d.type === "pair" ? 2.2 : 1.25));
|
||
|
||
const node = root.append("g")
|
||
.selectAll("g")
|
||
.data(nodes)
|
||
.join("g")
|
||
.style("cursor", "grab");
|
||
|
||
const nodeRadius = n > 220 ? 5.6 : n > 140 ? 6.6 : 8.5;
|
||
const labelFontSize = forceLabelFontSize(n);
|
||
const labelVisibleByDefault = n <= 140;
|
||
|
||
node.append("circle")
|
||
.attr("r", nodeRadius)
|
||
.attr("fill", (d) => baseColor(d.label))
|
||
.attr("stroke", "#ffffff")
|
||
.attr("stroke-width", 1.3);
|
||
|
||
node.append("text")
|
||
.text((d) => d.label)
|
||
.attr("font-size", labelFontSize)
|
||
.attr("font-weight", 700)
|
||
.attr("text-anchor", "middle")
|
||
.attr("dy", 3.4)
|
||
.attr("fill", "#122018")
|
||
.attr("opacity", labelVisibleByDefault ? 1 : 0);
|
||
|
||
node.append("title").text((d) => `${d.label}${d.id + 1}`);
|
||
|
||
let labelsVisible = labelVisibleByDefault;
|
||
let selectedId = null;
|
||
let relaxTimer = null;
|
||
const defaultAnchorStrength = n > 220 ? 0.085 : n > 140 ? 0.11 : 0.16;
|
||
const forceX = d3.forceX((d) => d.homeX).strength(defaultAnchorStrength);
|
||
const forceY = d3.forceY((d) => d.homeY).strength(defaultAnchorStrength);
|
||
const simulation = d3.forceSimulation(nodes)
|
||
.force("link", d3.forceLink(links).id((d) => d.id).distance((d) => {
|
||
const fallback = d.type === "pair" ? 48 : 26;
|
||
if (!Number.isFinite(d.anchorDistance)) return fallback;
|
||
return Math.max(d.type === "pair" ? 28 : 18, Math.min(d.type === "pair" ? 92 : 38, d.anchorDistance));
|
||
}).strength((d) => (d.type === "pair" ? Math.max(0.2, 0.22 + d.weight * 0.72) : 0.98)))
|
||
.force("charge", d3.forceManyBody().strength(Math.max(-38, -18 - n * 0.18)))
|
||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||
.force("x", forceX)
|
||
.force("y", forceY)
|
||
.force("collision", d3.forceCollide().radius(nodeRadius + 1.8))
|
||
.alpha(0.7)
|
||
.alphaDecay(n > 220 ? 0.1 : 0.075)
|
||
.velocityDecay(0.36);
|
||
|
||
function setLabelVisibility(visible) {
|
||
labelsVisible = visible;
|
||
node.select("text").attr("opacity", visible ? 1 : 0);
|
||
const toggle = container.querySelector("[data-force-labels]");
|
||
if (toggle) toggle.textContent = visible ? t("force_labels_on") : t("force_labels_off");
|
||
}
|
||
|
||
function applySelection() {
|
||
const highlightedNodes = new Set();
|
||
if (selectedId != null) {
|
||
highlightedNodes.add(selectedId);
|
||
const partner = nodes[selectedId]?.partner;
|
||
if (partner != null) highlightedNodes.add(partner);
|
||
}
|
||
node.select("circle")
|
||
.attr("stroke", (d) => (highlightedNodes.has(d.id) ? "#173e31" : "#ffffff"))
|
||
.attr("stroke-width", (d) => (highlightedNodes.has(d.id) ? 2.2 : 1.3))
|
||
.attr("opacity", (d) => (selectedId == null || highlightedNodes.has(d.id) ? 1 : 0.82));
|
||
node.select("text")
|
||
.attr("opacity", (d) => (
|
||
labelsVisible || highlightedNodes.has(d.id) || selectedId == null ? 1 : 0
|
||
))
|
||
.attr("font-weight", (d) => (highlightedNodes.has(d.id) ? 800 : 700));
|
||
link
|
||
.attr("stroke-opacity", (d) => {
|
||
if (selectedId == null) return d.type === "pair" ? 0.92 : 0.68;
|
||
const sourceId = d.source.id ?? d.source;
|
||
const targetId = d.target.id ?? d.target;
|
||
return sourceId === selectedId || targetId === selectedId ? 1 : (d.type === "pair" ? 0.16 : 0.12);
|
||
})
|
||
.attr("stroke-width", (d) => {
|
||
if (selectedId == null) return d.type === "pair" ? 2.2 : 1.25;
|
||
const sourceId = d.source.id ?? d.source;
|
||
const targetId = d.target.id ?? d.target;
|
||
return sourceId === selectedId || targetId === selectedId
|
||
? (d.type === "pair" ? 3.6 : 2.2)
|
||
: (d.type === "pair" ? 1.2 : 0.9);
|
||
});
|
||
renderForceInspector(inspector, selectedId == null ? null : nodes[selectedId]);
|
||
}
|
||
|
||
function resetNodePositions() {
|
||
nodes.forEach((nodeItem) => {
|
||
nodeItem.fx = null;
|
||
nodeItem.fy = null;
|
||
nodeItem.x = nodeItem.homeX;
|
||
nodeItem.y = nodeItem.homeY;
|
||
nodeItem.vx = 0;
|
||
nodeItem.vy = 0;
|
||
});
|
||
simulation.alpha(0.72).restart();
|
||
fitForceScene(d3, svg, zoom, width, height, nodes);
|
||
}
|
||
|
||
function relaxLayout() {
|
||
window.clearTimeout(relaxTimer);
|
||
forceX.strength(defaultAnchorStrength * 0.32);
|
||
forceY.strength(defaultAnchorStrength * 0.32);
|
||
simulation.alphaTarget(0.22).alpha(0.5).restart();
|
||
relaxTimer = window.setTimeout(() => {
|
||
forceX.strength(defaultAnchorStrength);
|
||
forceY.strength(defaultAnchorStrength);
|
||
simulation.alphaTarget(0);
|
||
}, 2200);
|
||
}
|
||
|
||
node.call(
|
||
d3.drag()
|
||
.on("start", (event, d) => {
|
||
if (!event.active) simulation.alphaTarget(0.28).restart();
|
||
d.fx = d.x;
|
||
d.fy = d.y;
|
||
})
|
||
.on("drag", (event, d) => {
|
||
d.fx = event.x;
|
||
d.fy = event.y;
|
||
})
|
||
.on("end", (event, d) => {
|
||
if (!event.active) simulation.alphaTarget(0);
|
||
d.fx = null;
|
||
d.fy = null;
|
||
})
|
||
);
|
||
|
||
node.on("click", (event, d) => {
|
||
event.stopPropagation();
|
||
selectedId = d.id === selectedId ? null : d.id;
|
||
applySelection();
|
||
});
|
||
svg.on("click", () => {
|
||
selectedId = null;
|
||
applySelection();
|
||
});
|
||
|
||
simulation.on("tick", () => {
|
||
nodes.forEach((nodeItem) => {
|
||
nodeItem.x = Math.max(28, Math.min(width - 28, nodeItem.x));
|
||
nodeItem.y = Math.max(28, Math.min(height - 28, nodeItem.y));
|
||
});
|
||
guide.selectAll("line")
|
||
.data(nodes)
|
||
.join("line")
|
||
.attr("x1", (d) => d.homeX)
|
||
.attr("y1", (d) => d.homeY)
|
||
.attr("x2", (d) => d.x)
|
||
.attr("y2", (d) => d.y)
|
||
.attr("opacity", (d) => {
|
||
const drift = Math.hypot(d.x - d.homeX, d.y - d.homeY);
|
||
return drift > 10 ? Math.min(0.4, drift / 180) : 0;
|
||
});
|
||
link
|
||
.attr("x1", (d) => d.source.x)
|
||
.attr("y1", (d) => d.source.y)
|
||
.attr("x2", (d) => d.target.x)
|
||
.attr("y2", (d) => d.target.y);
|
||
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
||
});
|
||
|
||
simulation.on("end", () => {
|
||
fitForceScene(d3, svg, zoom, width, height, nodes, 42, 220);
|
||
});
|
||
|
||
container.querySelector("[data-force-fit]")?.addEventListener("click", () => {
|
||
fitForceScene(d3, svg, zoom, width, height, nodes);
|
||
});
|
||
container.querySelector("[data-force-reset]")?.addEventListener("click", resetNodePositions);
|
||
container.querySelector("[data-force-relax]")?.addEventListener("click", relaxLayout);
|
||
container.querySelector("[data-force-labels]")?.addEventListener("click", () => {
|
||
setLabelVisibility(!labelsVisible);
|
||
applySelection();
|
||
});
|
||
|
||
fitForceScene(d3, svg, zoom, width, height, nodes, 42, 0);
|
||
applySelection();
|
||
container.dataset.rendered = "1";
|
||
}
|
||
|
||
function renderComplexCards(complexes) {
|
||
return complexes.map((complex, index) => {
|
||
const kv = [];
|
||
if (complex.pfunc) kv.push(`<div><strong>pfunc</strong><span class="metric-value">${complex.pfunc}</span></div>`);
|
||
if (complex.free_energy_kcal_mol !== undefined) kv.push(`<div><strong>${t("complex_free_energy")}</strong><span class="metric-value">${complex.free_energy_kcal_mol} kcal/mol</span></div>`);
|
||
if (complex.ensemble_size !== undefined) kv.push(`<div><strong>${t("complex_ensemble_size")}</strong><span class="metric-value">${complex.ensemble_size}</span></div>`);
|
||
if (complex.mfe_stack_kcal_mol !== undefined) kv.push(`<div><strong>${t("complex_mfe_stack")}</strong><span class="metric-value">${complex.mfe_stack_kcal_mol} kcal/mol</span></div>`);
|
||
|
||
const blocks = [];
|
||
if (complex.mfe?.length) {
|
||
const first = complex.mfe[0];
|
||
const styledMfeSvg = first.rnaplot_svg ? getStyledMfeSvg(index, first) : "";
|
||
const rawSvgMarkup = styledMfeSvg ? `<div class="svg-wrap" data-mfe-svg-wrap="${index}">${styledMfeSvg}</div>` : "";
|
||
const probabilityEnabled = Boolean(first.rnaplot_svg && first.pair_probability_annotations);
|
||
const svgErr = first.rnaplot_error ? `<div class="error-box">${first.rnaplot_error}</div>` : "";
|
||
const exportButtons = first.rnaplot_svg ? `
|
||
<div class="compact-actions" style="margin:8px 0 12px;">
|
||
<button class="secondary" data-svg-export="${index}" type="button">${t("export_svg")}</button>
|
||
<button class="secondary" data-svg-fullscreen="${index}" type="button">${t("fullscreen_svg")}</button>
|
||
${probabilityEnabled ? `<button class="secondary" data-prob-svg-export="${index}" type="button">${t("export_prob_svg")}</button>` : ""}
|
||
</div>
|
||
` : "";
|
||
const switcher = `
|
||
<div class="view-switch">
|
||
<label><input type="radio" name="view-${index}" value="mfe" checked data-view-toggle="${index}" />${t("mfe_view")}</label>
|
||
${probabilityEnabled ? `<label><input type="radio" name="view-${index}" value="pairs" data-view-toggle="${index}" />${t("pair_view")}</label>` : ""}
|
||
<label><input type="radio" name="view-${index}" value="force" data-view-toggle="${index}" />${t("force_view")}</label>
|
||
</div>
|
||
`;
|
||
const panels = `
|
||
<div class="view-panel" data-view-panel="${index}-mfe">${rawSvgMarkup}</div>
|
||
${probabilityEnabled ? `<div class="view-panel hidden" data-view-panel="${index}-pairs"><div data-probability-container="${index}"></div></div>` : ""}
|
||
<div class="view-panel hidden" data-view-panel="${index}-force"><div data-force-container="${index}"></div></div>
|
||
`;
|
||
const structureJson = `
|
||
<details>
|
||
<summary>${t("toggle_structure_data")}</summary>
|
||
<p class="section-note">${t("hidden_by_default")}</p>
|
||
<pre>${escapeHtml(JSON.stringify(buildPreviewPayload(complex.mfe), null, 2))}</pre>
|
||
</details>
|
||
`;
|
||
blocks.push(`<strong>${t("complex_mfe")}</strong>${exportButtons}${switcher}<div class="structure-stack">${panels}</div>${svgErr}${structureJson}`);
|
||
}
|
||
if (complex.subopt?.length) {
|
||
blocks.push(`
|
||
<strong>${t("complex_subopt")}</strong>
|
||
<details>
|
||
<summary>${t("toggle_structure_data")}</summary>
|
||
<p class="section-note">${t("hidden_by_default")}</p>
|
||
<pre>${escapeHtml(JSON.stringify(buildPreviewPayload(complex.subopt), null, 2))}</pre>
|
||
</details>
|
||
`);
|
||
}
|
||
if (complex.sample?.length) {
|
||
blocks.push(`
|
||
<strong>${t("complex_sample")}</strong>
|
||
<details>
|
||
<summary>${t("toggle_structure_data")}</summary>
|
||
<p class="section-note">${t("hidden_by_default")}</p>
|
||
<pre>${escapeHtml(JSON.stringify(buildPreviewPayload(complex.sample), null, 2))}</pre>
|
||
</details>
|
||
`);
|
||
}
|
||
if (complex.pairs) {
|
||
blocks.push(`
|
||
<strong>${t("complex_pairs")}</strong>
|
||
<details>
|
||
<summary>${t("toggle_pairs_panel")}</summary>
|
||
<p class="section-note">${t("hidden_by_default")}</p>
|
||
<div class="heatmap-shell">
|
||
<p class="section-note">${t("heatmap_caption")}</p>
|
||
<div class="heatmap-toolbar">
|
||
<span>${complex.pairs.shape[0]} × ${complex.pairs.shape[1]}</span>
|
||
<div class="heatmap-legend">
|
||
<span>${t("heatmap_legend")}</span>
|
||
<div class="heatmap-legend-bar"></div>
|
||
<span>0 → 1</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="pairs-${index}"></canvas>
|
||
</div>
|
||
<details>
|
||
<summary>${t("details_json")}</summary>
|
||
<pre>${escapeHtml(JSON.stringify(buildPreviewPayload(complex.pairs), null, 2))}</pre>
|
||
</details>
|
||
</details>
|
||
`);
|
||
}
|
||
|
||
const metaLine = [];
|
||
if (complex.concentration_M !== undefined) {
|
||
metaLine.push(`<span class="meta-chip">${t("concentration_label")} ${complex.concentration_M.toExponential(3)} M</span>`);
|
||
} else {
|
||
metaLine.push(`<span class="meta-chip">${t("no_concentration")}</span>`);
|
||
}
|
||
metaLine.push(`<span class="meta-chip">${complex.strand_names.join(" + ")}</span>`);
|
||
|
||
return `
|
||
<div class="result-card" data-complex-card="${index}">
|
||
<div class="result-card-header">
|
||
<h3>${complex.display}</h3>
|
||
<div class="meta-line">${metaLine.join("")}</div>
|
||
</div>
|
||
<div class="kv">${kv.join("")}</div>
|
||
${blocks.join("")}
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function renderRawCard(result) {
|
||
return `
|
||
<div class="result-card">
|
||
<h3>${t("raw_json")}</h3>
|
||
<p class="raw-summary">${t("raw_json_caption")}</p>
|
||
<details>
|
||
<summary>${t("details_json")}</summary>
|
||
<pre>${escapeHtml(JSON.stringify(buildPreviewPayload(result), null, 2))}</pre>
|
||
</details>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function exportPairsCsv(result) {
|
||
const sections = [];
|
||
let exportedCount = 0;
|
||
result.complexes.forEach((complex) => {
|
||
const preview = complex.pairs?.preview;
|
||
if (!preview?.length) return;
|
||
exportedCount += 1;
|
||
const n = preview.length;
|
||
sections.push(`# ${complex.display}`);
|
||
sections.push(["i/j", ...Array.from({ length: n }, (_, i) => `${i + 1}`)].join(","));
|
||
for (let i = 0; i < n; i += 1) {
|
||
const row = preview[i] || [];
|
||
sections.push([
|
||
`${i + 1}`,
|
||
...row.map((value) => Number(value).toFixed(6)),
|
||
].join(","));
|
||
}
|
||
sections.push("");
|
||
});
|
||
if (!exportedCount) {
|
||
alert(t("csv_no_pairs"));
|
||
return;
|
||
}
|
||
downloadText("nupack-pairs-preview.csv", sections.join("\n"), "text/csv;charset=utf-8");
|
||
}
|
||
|
||
function renderResult(result) {
|
||
probabilitySvgCache = new Map();
|
||
styledSvgCache = new Map();
|
||
if (workflowKind(result) === "design") {
|
||
results.innerHTML = `
|
||
<div class="result-grid">
|
||
${renderModelCard(result)}
|
||
${renderDesignSummaryCard(result)}
|
||
${renderDesignDomainsCard(result)}
|
||
${renderDesignStrandsCard(result)}
|
||
${renderSummaryCard(result)}
|
||
${renderDesignTargetsCard(result)}
|
||
${renderDesignDefectsCard(result)}
|
||
${renderRawCard(result)}
|
||
</div>
|
||
`;
|
||
} else {
|
||
results.innerHTML = `
|
||
<div class="result-grid">
|
||
${renderModelCard(result)}
|
||
${renderSummaryCard(result)}
|
||
${renderTubeCard(result.tube)}
|
||
${renderComplexCards(result.complexes)}
|
||
${renderRawCard(result)}
|
||
</div>
|
||
`;
|
||
}
|
||
hydrateHeatmaps(result);
|
||
}
|
||
|
||
function hydrateHeatmaps(result) {
|
||
const ensureProbabilityView = (index) => {
|
||
const container = document.querySelector(`[data-probability-container="${index}"]`);
|
||
if (!container || container.dataset.rendered === "1") return;
|
||
const mfe = result.complexes[index]?.mfe?.[0];
|
||
const probabilitySvgMarkup = getProbabilitySvg(index, mfe);
|
||
container.innerHTML = probabilitySvgMarkup
|
||
? `<p class="probability-caption">${t("probability_caption")}</p><div class="probability-figure"><div class="svg-wrap" data-probability-svg-wrap="${index}">${probabilitySvgMarkup}</div><div class="probability-legend"><div class="probability-legend-title">${t("probability_legend_title")}</div><span>${t("probability_legend_high")}</span><div class="probability-legend-bar"></div><span>${t("probability_legend_low")}</span></div></div>`
|
||
: `<div class="error-box">${t("request_failed")}</div>`;
|
||
container.dataset.rendered = "1";
|
||
};
|
||
const ensureForceView = (index) => {
|
||
const container = document.querySelector(`[data-force-container="${index}"]`);
|
||
if (!container || container.dataset.rendered === "1") return;
|
||
const complex = result.complexes[index];
|
||
const mfe = complex?.mfe?.[0];
|
||
container.innerHTML = `<p class="section-note">Loading force graph...</p>`;
|
||
loadD3()
|
||
.then(() => renderForceView(container, complex, mfe))
|
||
.catch((error) => {
|
||
container.innerHTML = `<div class="error-box">${escapeHtml(error.message)}</div>`;
|
||
container.dataset.rendered = "1";
|
||
});
|
||
};
|
||
|
||
if (result.tube?.complex_concentrations?.length) {
|
||
drawTubeChart(document.getElementById("tube-chart"), result.tube.complex_concentrations);
|
||
drawTubePieChart(
|
||
document.getElementById("tube-pie-chart"),
|
||
document.getElementById("tube-pie-legend"),
|
||
result.tube.complex_concentrations
|
||
);
|
||
document.querySelectorAll("[data-tube-chart-view]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const value = button.dataset.tubeChartView;
|
||
document.querySelectorAll("[data-tube-chart-view]").forEach((item) => item.classList.toggle("active", item === button));
|
||
document.querySelectorAll("[data-tube-panel]").forEach((panel) => {
|
||
panel.classList.toggle("hidden", panel.dataset.tubePanel !== value);
|
||
});
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-tube-table-index]").forEach((row) => {
|
||
row.addEventListener("click", () => {
|
||
const index = Number(row.dataset.tubeTableIndex);
|
||
if (!Number.isNaN(index)) {
|
||
const label = row.querySelector("td")?.textContent?.trim() || "";
|
||
scrollToComplexCard(index, label);
|
||
}
|
||
});
|
||
row.style.cursor = row.dataset.tubeTableIndex ? "pointer" : "";
|
||
});
|
||
}
|
||
if (workflowKind(result) !== "analysis") {
|
||
document.getElementById("exportInputBtn")?.addEventListener("click", () => {
|
||
if (lastPayload) downloadText("nupack-design-input.json", JSON.stringify(lastPayload, null, 2));
|
||
});
|
||
document.getElementById("exportResultBtn")?.addEventListener("click", () => {
|
||
if (lastResult) downloadText("nupack-design-result.json", JSON.stringify(lastResult, null, 2));
|
||
});
|
||
document.getElementById("loadDesignedAnalysisBtn")?.addEventListener("click", () => {
|
||
try {
|
||
loadDesignedAnalysisInput(result);
|
||
} catch (error) {
|
||
setStatus(`${t("status_error")}: ${error.message}`, true);
|
||
}
|
||
});
|
||
document.getElementById("runDesignedAnalysisBtn")?.addEventListener("click", runDesignedAnalysis);
|
||
return;
|
||
}
|
||
result.complexes.forEach((complex, index) => {
|
||
if (!complex.pairs) return;
|
||
const canvas = document.getElementById(`pairs-${index}`);
|
||
if (!canvas) return;
|
||
const details = canvas.closest("details");
|
||
const render = () => {
|
||
if (canvas.dataset.rendered === "1") return;
|
||
drawHeatmap(canvas, complex.pairs);
|
||
canvas.dataset.rendered = "1";
|
||
};
|
||
if (details?.open) {
|
||
render();
|
||
} else if (details) {
|
||
details.addEventListener("toggle", () => {
|
||
if (details.open) render();
|
||
}, { once: true });
|
||
}
|
||
});
|
||
document.getElementById("exportInputBtn")?.addEventListener("click", () => {
|
||
if (lastPayload) downloadText("nupack-input.json", JSON.stringify(lastPayload, null, 2));
|
||
});
|
||
document.getElementById("exportResultBtn")?.addEventListener("click", () => {
|
||
if (lastResult) downloadText("nupack-result.json", JSON.stringify(lastResult, null, 2));
|
||
});
|
||
document.getElementById("exportPairsCsvBtn")?.addEventListener("click", () => {
|
||
if (lastResult) exportPairsCsv(lastResult);
|
||
});
|
||
document.querySelectorAll("[data-view-toggle]").forEach((input) => {
|
||
input.addEventListener("change", () => {
|
||
const index = input.dataset.viewToggle;
|
||
const value = input.value;
|
||
if (value === "pairs") ensureProbabilityView(index);
|
||
if (value === "force") ensureForceView(index);
|
||
document.querySelectorAll(`[data-view-panel^="${index}-"]`).forEach((panel) => {
|
||
panel.classList.add("hidden");
|
||
});
|
||
document.querySelector(`[data-view-panel="${index}-${value}"]`)?.classList.remove("hidden");
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleResultAction(event) {
|
||
if (!lastResult || workflowKind(lastResult) !== "analysis") return;
|
||
|
||
const svgWrap = event.target.closest("[data-mfe-svg-wrap]");
|
||
if (svgWrap) {
|
||
openViewer(svgWrap.innerHTML.trim());
|
||
return;
|
||
}
|
||
|
||
const probabilitySvgWrap = event.target.closest("[data-probability-svg-wrap]");
|
||
if (probabilitySvgWrap) {
|
||
openViewer(probabilitySvgWrap.innerHTML.trim());
|
||
return;
|
||
}
|
||
|
||
const exportBtn = event.target.closest("[data-svg-export]");
|
||
if (exportBtn) {
|
||
const index = Number(exportBtn.dataset.svgExport);
|
||
const svgMarkup = getRenderedMfeSvgMarkup(index) || getStyledMfeSvg(index, lastResult.complexes[index]?.mfe?.[0]);
|
||
downloadSvgMarkup(`complex-${index + 1}.svg`, svgMarkup);
|
||
return;
|
||
}
|
||
|
||
const fullscreenBtn = event.target.closest("[data-svg-fullscreen]");
|
||
if (fullscreenBtn) {
|
||
const index = Number(fullscreenBtn.dataset.svgFullscreen);
|
||
const svgMarkup = getActiveStructureSvg(index);
|
||
openViewer(svgMarkup);
|
||
return;
|
||
}
|
||
|
||
const probBtn = event.target.closest("[data-prob-svg-export]");
|
||
if (probBtn) {
|
||
const index = Number(probBtn.dataset.probSvgExport);
|
||
const mfe = lastResult.complexes[index]?.mfe?.[0];
|
||
const svgMarkup = getProbabilitySvg(index, mfe);
|
||
downloadSvgMarkup(`complex-${index + 1}-probability.svg`, svgMarkup);
|
||
}
|
||
}
|
||
|
||
async function waitForJob(jobId) {
|
||
for (;;) {
|
||
const { response, data: job } = await fetchJsonOrThrow(`/api/jobs/${jobId}`);
|
||
if (!response.ok) {
|
||
throw new Error(job?.error || `Job request failed with HTTP ${response.status}`);
|
||
}
|
||
if (job.status === "queued") {
|
||
setStatus(formatStatus(t("status_queued")));
|
||
} else if (job.status === "running") {
|
||
setStatus(formatStatus(t("status_polling")));
|
||
} else if (job.status === "cancel_requested") {
|
||
setStatus(formatStatus(t("status_canceling")));
|
||
} else if (job.status === "canceled") {
|
||
refreshHealth();
|
||
throw new Error(t("status_canceled"));
|
||
} else if (job.status === "success") {
|
||
refreshHealth();
|
||
if (job.result && typeof job.elapsed_seconds === "number") {
|
||
job.result.job_elapsed_seconds = job.elapsed_seconds;
|
||
}
|
||
return job.result;
|
||
} else if (job.status === "error") {
|
||
refreshHealth();
|
||
throw new Error(job.error?.message || "Job failed");
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||
}
|
||
}
|
||
|
||
async function runDesignedAnalysis() {
|
||
if (activeSubmission) {
|
||
setStatus(formatStatus(t("submission_locked")));
|
||
return;
|
||
}
|
||
let payload;
|
||
try {
|
||
payload = buildDesignedAnalysisPayload(lastResult);
|
||
const validationError = validatePayload(payload);
|
||
if (validationError) throw new Error(validationError);
|
||
} catch (error) {
|
||
saveFailedExample(payload || lastPayload, error, { stage: "designed_analysis_validation", workflow: "analysis" });
|
||
setStatus(`${t("status_error")}: ${error.message}`, true);
|
||
return;
|
||
}
|
||
|
||
currentJobId = null;
|
||
cancellationRequested = false;
|
||
setSubmissionLocked(true);
|
||
setStatus(formatStatus(t("status_running")));
|
||
results.innerHTML = "";
|
||
|
||
try {
|
||
refreshHealth();
|
||
const { response, data } = await fetchJsonOrThrow("/api/jobs", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok || data.status !== "accepted") {
|
||
throw new Error(data?.error || `Analysis failed with HTTP ${response.status}`);
|
||
}
|
||
currentJobId = data.job_id;
|
||
updateRunButtonState();
|
||
setStatus(formatStatus(t("status_waiting_job")));
|
||
const result = await waitForJob(data.job_id);
|
||
result.job_id = data.job_id;
|
||
lastPayload = payload;
|
||
lastResult = result;
|
||
renderResult(result);
|
||
saveHistory(payload, result);
|
||
renderHistory();
|
||
setStatus(formatStatus(t("status_done")));
|
||
} catch (error) {
|
||
if (cancellationRequested) {
|
||
results.innerHTML = `<div class="result-card"><h3>${t("status_canceled")}</h3><pre>${String(error)}</pre></div>`;
|
||
setStatus(formatStatus(t("status_canceled")));
|
||
} else {
|
||
saveFailedExample(payload, error, { stage: "designed_analysis_job", jobId: currentJobId });
|
||
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><pre>${String(error)}</pre></div>`;
|
||
setStatus(t("status_error"), true);
|
||
}
|
||
refreshHealth();
|
||
} finally {
|
||
setSubmissionLocked(false);
|
||
currentJobId = null;
|
||
cancellationRequested = false;
|
||
updateRunButtonState();
|
||
void refreshAccountWorkspace();
|
||
}
|
||
}
|
||
|
||
async function runAnalysis() {
|
||
if (activeSubmission) {
|
||
setStatus(formatStatus(t("submission_locked")));
|
||
return;
|
||
}
|
||
const payload = buildPayload();
|
||
const validationError = validatePayload(payload);
|
||
if (validationError) {
|
||
saveFailedExample(payload, new Error(validationError), { stage: "validation" });
|
||
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><div class="error-box">${validationError}</div></div>`;
|
||
setStatus(t("status_error"), true);
|
||
return;
|
||
}
|
||
|
||
currentJobId = null;
|
||
cancellationRequested = false;
|
||
setSubmissionLocked(true);
|
||
setStatus(formatStatus(t("status_running")));
|
||
results.innerHTML = "";
|
||
|
||
try {
|
||
refreshHealth();
|
||
const { response, data } = await fetchJsonOrThrow("/api/jobs", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!response.ok || data.status !== "accepted") {
|
||
throw new Error(data?.error || `Analysis failed with HTTP ${response.status}`);
|
||
}
|
||
currentJobId = data.job_id;
|
||
updateRunButtonState();
|
||
setStatus(formatStatus(t("status_waiting_job")));
|
||
const result = await waitForJob(data.job_id);
|
||
result.job_id = data.job_id;
|
||
lastPayload = payload;
|
||
lastResult = result;
|
||
renderResult(result);
|
||
saveHistory(payload, result);
|
||
renderHistory();
|
||
setStatus(formatStatus(t("status_done")));
|
||
} catch (error) {
|
||
if (cancellationRequested) {
|
||
results.innerHTML = `<div class="result-card"><h3>${t("status_canceled")}</h3><pre>${String(error)}</pre></div>`;
|
||
setStatus(formatStatus(t("status_canceled")));
|
||
} else {
|
||
saveFailedExample(payload, error, { stage: "job", jobId: currentJobId });
|
||
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><pre>${String(error)}</pre></div>`;
|
||
setStatus(t("status_error"), true);
|
||
}
|
||
refreshHealth();
|
||
} finally {
|
||
setSubmissionLocked(false);
|
||
currentJobId = null;
|
||
cancellationRequested = false;
|
||
updateRunButtonState();
|
||
void refreshAccountWorkspace();
|
||
}
|
||
}
|
||
|
||
async function loadExample() {
|
||
const params = new URLSearchParams({
|
||
workflow: workflowSelect.value,
|
||
mode: modeSelect.value,
|
||
});
|
||
const { response, data: example } = await fetchJsonOrThrow(`/api/example?${params.toString()}`);
|
||
if (!response.ok) {
|
||
throw new Error(`Example request failed with HTTP ${response.status}`);
|
||
}
|
||
workflowSelect.value = example.workflow || "analysis";
|
||
modeSelect.value = example.mode;
|
||
document.getElementById("material").value = example.model.material;
|
||
document.getElementById("ensemble").value = example.model.ensemble;
|
||
document.getElementById("celsius").value = example.model.celsius;
|
||
document.getElementById("sodium").value = example.model.sodium;
|
||
document.getElementById("magnesium").value = example.model.magnesium;
|
||
document.getElementById("numSample").value = example.options.num_sample;
|
||
document.getElementById("energyGap").value = example.options.energy_gap;
|
||
document.getElementById("sparsityFraction").value = example.options.sparsity_fraction;
|
||
document.getElementById("sparsityThreshold").value = example.options.sparsity_threshold;
|
||
document.getElementById("resultLimit").value = example.options.result_limit ?? 25;
|
||
document.getElementById("pairsPreviewSize").value = example.options.pairs_preview_size ?? 24;
|
||
document.getElementById("tubeName").value = example.tube.name;
|
||
document.getElementById("maxSize").value = example.tube.max_size;
|
||
document.getElementById("designTrials").value = example.design?.trials ?? 1;
|
||
document.getElementById("designStopCondition").value = example.design?.stop_condition ?? 0.02;
|
||
document.getElementById("designSeed").value = example.design?.seed ?? 0;
|
||
document.getElementById("designWobble").value = String(example.design?.wobble_mutations ?? false);
|
||
document.getElementById("designMaxTimeHours").value = ((example.design?.max_time_seconds ?? 0) / 3600);
|
||
document.getElementById("designFixedTargetPolicy").value = example.design?.fixed_target_policy ?? "exclude_from_optimization";
|
||
document.getElementById("complexesText").value = example.complexes_text;
|
||
const exampleCompute = Array.isArray(example.compute) ? example.compute : [];
|
||
document.querySelectorAll(".check input").forEach((input) => {
|
||
input.checked = exampleCompute.includes(input.value);
|
||
});
|
||
strandList.innerHTML = "";
|
||
example.strands.forEach((strand) => addStrandRow(strand));
|
||
designDomainList.innerHTML = "";
|
||
(example.design_domains || []).forEach((domain) => addDesignDomainRow(domain));
|
||
syncComplexRowsFromText(example.complexes_text);
|
||
designComplexList.innerHTML = "";
|
||
(example.design_complexes || example.design_targets || []).forEach((target) => addDesignComplexRow(target));
|
||
if (!(example.design_complexes || example.design_targets)?.length) addDesignComplexRow({ name: "AB_target", strands: "A+B", structure: "(10+)10" });
|
||
designTubeList.innerHTML = "";
|
||
(example.design_tubes || []).forEach((tube) => addDesignTubeRow(tube));
|
||
if (!(example.design_tubes || []).length) {
|
||
addDesignTubeRow({
|
||
name: example.tube?.name || "Tube 1",
|
||
max_size: example.design?.off_target_max_size ?? example.tube?.max_size ?? 2,
|
||
on_targets: [{ complex: "AB_target", concentration: 1.0, unit: "uM" }],
|
||
});
|
||
}
|
||
hardConstraintList.innerHTML = "";
|
||
(example.hard_constraints || []).forEach((row) => addHardConstraintRow(row));
|
||
softConstraintList.innerHTML = "";
|
||
(example.soft_constraints || []).forEach((row) => addSoftConstraintRow(row));
|
||
defectWeightList.innerHTML = "";
|
||
(example.defect_weights || []).forEach((row) => addDefectWeightRow(row));
|
||
updateDesignReferenceLists();
|
||
syncWorkflow();
|
||
syncMode();
|
||
if (workflowSelect.value === "design") {
|
||
setActiveDesignTab("targets");
|
||
}
|
||
}
|
||
|
||
function syncWorkflow() {
|
||
const isDesign = workflowSelect.value === "design";
|
||
document.querySelectorAll(".check input").forEach((input) => {
|
||
input.disabled = isDesign;
|
||
});
|
||
strandList.querySelectorAll("[data-strand-concentration], [data-strand-unit]").forEach((node) => {
|
||
node.classList.toggle("hidden", isDesign);
|
||
});
|
||
strandList.querySelectorAll("[data-strand-sequence-label]").forEach((node) => {
|
||
node.textContent = isDesign ? t("strand_sequence_design") : t("strand_sequence");
|
||
});
|
||
strandList.querySelectorAll('[data-field="sequence"]').forEach((node) => {
|
||
node.placeholder = isDesign ? t("strand_sequence_design_placeholder") : "AGUCUAGGAU";
|
||
});
|
||
updateRunButtonState();
|
||
computeSection.classList.toggle("hidden", isDesign);
|
||
optionsSection.classList.toggle("hidden", isDesign);
|
||
colorSettingsSection.classList.toggle("hidden", isDesign);
|
||
designSection.classList.toggle("hidden", !isDesign);
|
||
complexSection.classList.toggle("hidden", isDesign || modeSelect.value !== "complex");
|
||
designTabButtons.forEach((button) => {
|
||
button.classList.toggle("hidden", !isDesign);
|
||
});
|
||
}
|
||
|
||
function syncMode() {
|
||
const isTube = modeSelect.value === "tube";
|
||
const isDesign = workflowSelect.value === "design";
|
||
tubeSection.classList.toggle("hidden", !isTube);
|
||
complexSection.classList.toggle("hidden", isDesign || isTube);
|
||
designSection.classList.toggle("hidden", !isDesign);
|
||
designTubeEditor.classList.toggle("hidden", !isDesign || !isTube);
|
||
defectWeightList.querySelectorAll('[data-field="kind"] option[value="tube"]').forEach((node) => {
|
||
node.disabled = !isTube;
|
||
});
|
||
modeSelect.options[0].textContent = isDesign ? t("mode_tube_design") : t("mode_tube");
|
||
modeSelect.options[1].textContent = isDesign ? t("mode_complex_design") : t("mode_complex");
|
||
}
|
||
|
||
function applyNavigationHash() {
|
||
const target = window.location.hash.replace(/^#/, "");
|
||
const designTargets = {
|
||
"design-targets": "targets",
|
||
"design-hard": "hard",
|
||
"design-soft": "soft",
|
||
"design-weights": "weights",
|
||
};
|
||
if (designTargets[target]) {
|
||
workflowSelect.value = "design";
|
||
syncWorkflow();
|
||
syncMode();
|
||
setActiveDesignTab(designTargets[target]);
|
||
designSection.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
if (target === "history") {
|
||
document.getElementById("historySection").scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
}
|
||
|
||
languageSelect.addEventListener("change", () => {
|
||
currentLang = languageSelect.value;
|
||
applyTranslations();
|
||
});
|
||
themeSelect.addEventListener("change", () => {
|
||
currentTheme = themeSelect.value;
|
||
applyTheme();
|
||
});
|
||
layoutSelect.addEventListener("change", () => {
|
||
currentLayout = layoutSelect.value;
|
||
applyLayout();
|
||
});
|
||
helpToggle.addEventListener("change", () => {
|
||
currentHelpVisibility = helpToggle.value;
|
||
applyHelpVisibility();
|
||
});
|
||
colorLowInput.addEventListener("input", refreshStylesFromControls);
|
||
colorHighInput.addEventListener("input", refreshStylesFromControls);
|
||
graphFontSizeSelect.addEventListener("change", refreshGraphFontSize);
|
||
document.getElementById("resetColorBtn").addEventListener("click", () => {
|
||
heatmapColors = { low: "#f7f4e7", high: "#579b3b" };
|
||
colorLowInput.value = heatmapColors.low;
|
||
colorHighInput.value = heatmapColors.high;
|
||
refreshStylesFromControls();
|
||
});
|
||
closeViewerBtn.addEventListener("click", closeViewer);
|
||
zoomInBtn.addEventListener("click", () => {
|
||
viewerScale = Math.min(4, Number((viewerScale + 0.2).toFixed(2)));
|
||
syncViewerScale();
|
||
});
|
||
zoomOutBtn.addEventListener("click", () => {
|
||
viewerScale = Math.max(0.4, Number((viewerScale - 0.2).toFixed(2)));
|
||
syncViewerScale();
|
||
});
|
||
zoomResetBtn.addEventListener("click", () => {
|
||
viewerScale = 1;
|
||
viewerPanX = 0;
|
||
viewerPanY = 0;
|
||
syncViewerScale();
|
||
});
|
||
viewerBody.addEventListener("pointerdown", (event) => {
|
||
if (viewer.classList.contains("hidden")) return;
|
||
if (!viewerBody.querySelector("svg")) return;
|
||
viewerDragState = {
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
panX: viewerPanX,
|
||
panY: viewerPanY,
|
||
};
|
||
viewerBody.setPointerCapture?.(event.pointerId);
|
||
viewerBody.style.cursor = "grabbing";
|
||
});
|
||
viewerBody.addEventListener("pointermove", (event) => {
|
||
if (!viewerDragState) return;
|
||
viewerPanX = viewerDragState.panX + (event.clientX - viewerDragState.x);
|
||
viewerPanY = viewerDragState.panY + (event.clientY - viewerDragState.y);
|
||
syncViewerScale();
|
||
});
|
||
const stopViewerDrag = () => {
|
||
viewerDragState = null;
|
||
viewerBody.style.cursor = "";
|
||
};
|
||
viewerBody.addEventListener("pointerup", stopViewerDrag);
|
||
viewerBody.addEventListener("pointercancel", stopViewerDrag);
|
||
viewerBody.addEventListener("pointerleave", (event) => {
|
||
if (viewerDragState && !(event.buttons & 1)) stopViewerDrag();
|
||
});
|
||
viewer.addEventListener("click", (event) => {
|
||
if (event.target === viewer) closeViewer();
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && !viewer.classList.contains("hidden")) closeViewer();
|
||
});
|
||
document.getElementById("addStrandBtn").addEventListener("click", () => addStrandRow());
|
||
document.getElementById("addComplexBtn").addEventListener("click", () => {
|
||
addComplexRow("");
|
||
syncComplexTextFromRows();
|
||
});
|
||
document.getElementById("addDesignDomainBtn").addEventListener("click", () => {
|
||
addDesignDomainRow({ sequence: "N10" });
|
||
});
|
||
document.getElementById("addDesignComplexBtn").addEventListener("click", () => addDesignComplexRow({}));
|
||
document.getElementById("addDesignTubeBtn").addEventListener("click", () => addDesignTubeRow({ name: "Tube", max_size: 2 }));
|
||
document.getElementById("addHardConstraintBtn").addEventListener("click", () => addHardConstraintRow({ type: "match" }));
|
||
document.getElementById("addSoftConstraintBtn").addEventListener("click", () => addSoftConstraintRow({ type: "pattern", weight: 1.0 }));
|
||
document.getElementById("addDefectWeightBtn").addEventListener("click", () => addDefectWeightRow({ kind: "global", weight: 1.0 }));
|
||
document.querySelectorAll("[data-hard-preset]").forEach((button) => {
|
||
button.addEventListener("click", () => addHardConstraintPreset(button.dataset.hardPreset));
|
||
});
|
||
document.querySelectorAll("[data-soft-preset]").forEach((button) => {
|
||
button.addEventListener("click", () => addSoftConstraintPreset(button.dataset.softPreset));
|
||
});
|
||
document.getElementById("designGuideBtn").addEventListener("click", () => {
|
||
window.open("/design-guide.html", "_blank", "noopener");
|
||
});
|
||
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
|
||
refreshAccountWorkspace();
|
||
});
|
||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||
exportHistory().catch((error) => setStatus(`${t("status_error")}: ${error.message}`, true));
|
||
});
|
||
document.getElementById("importHistoryBtn").addEventListener("click", () => historyImportInput.click());
|
||
historyImportInput.addEventListener("change", async () => {
|
||
try {
|
||
await importHistoryFiles(historyImportInput.files);
|
||
} catch (error) {
|
||
setStatus(`${t("history_import_failed")}: ${error.message}`, true);
|
||
} finally {
|
||
historyImportInput.value = "";
|
||
}
|
||
});
|
||
let historyFilterTimer = null;
|
||
const applyHistoryFilters = () => {
|
||
clearTimeout(historyFilterTimer);
|
||
historyFilterTimer = setTimeout(() => {
|
||
historyOffset = 0;
|
||
loadCloudHistory().catch((error) => setStatus(error.message, true));
|
||
}, 180);
|
||
};
|
||
historySearch.addEventListener("input", applyHistoryFilters);
|
||
[historyStatus, historyWorkflow, historyMode, historyMaterial].forEach((node) => node.addEventListener("change", applyHistoryFilters));
|
||
historyPrev.addEventListener("click", () => {
|
||
historyOffset = Math.max(0, historyOffset - HISTORY_PAGE_SIZE);
|
||
loadCloudHistory().catch((error) => setStatus(error.message, true));
|
||
});
|
||
historyNext.addEventListener("click", () => {
|
||
historyOffset += HISTORY_PAGE_SIZE;
|
||
loadCloudHistory().catch((error) => setStatus(error.message, true));
|
||
});
|
||
runBtn.addEventListener("click", () => {
|
||
if (activeSubmission) {
|
||
cancelCurrentJob();
|
||
return;
|
||
}
|
||
runAnalysis();
|
||
});
|
||
document.getElementById("exampleBtn").addEventListener("click", loadExample);
|
||
exportFailedExamplesBtn.addEventListener("click", exportFailedExamples);
|
||
workflowSelect.addEventListener("change", () => {
|
||
syncWorkflow();
|
||
syncMode();
|
||
});
|
||
modeSelect.addEventListener("change", syncMode);
|
||
designTabButtons.forEach((button) => {
|
||
button.addEventListener("click", () => setActiveDesignTab(button.dataset.designTab));
|
||
});
|
||
results.addEventListener("click", handleResultAction);
|
||
window.addEventListener("hashchange", applyNavigationHash);
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "visible") {
|
||
refreshHealth();
|
||
refreshAccountWorkspace();
|
||
}
|
||
});
|
||
|
||
addStrandRow({ name: "A", sequence: "AGUCUAGGAU", concentration: 1.0, unit: "uM" });
|
||
addStrandRow({ name: "B", sequence: "UUAACCCACG", concentration: 2.0, unit: "uM" });
|
||
syncComplexRowsFromText("A\nB\nA+B");
|
||
addDesignDomainRow({ name: "a", sequence: "N10" });
|
||
addDesignDomainRow({ name: "b", sequence: "N10" });
|
||
addDesignComplexRow({ name: "AB_target", strands: "A+B", structure: "(10+)10" });
|
||
addDesignTubeRow({
|
||
name: "Tube 1",
|
||
max_size: 2,
|
||
on_targets: [{ complex: "AB_target", concentration: 1.0, unit: "uM" }],
|
||
});
|
||
setActiveDesignTab("targets");
|
||
updateDesignReferenceLists();
|
||
applyTheme();
|
||
applyLayout();
|
||
applyHelpVisibility();
|
||
applyHeatmapColors();
|
||
startHealthPolling();
|
||
applyTranslations();
|
||
applyNavigationHash();
|
||
const isShareView = /^\/share\/[A-Za-z0-9_-]{8,128}$/.test(window.location.pathname)
|
||
|| new URLSearchParams(window.location.search).has("share");
|
||
if (isShareView) loadSharedHistoryFromUrl();
|
||
else initializeAccount();
|
||
</script>
|
||
</body>
|
||
</html>
|