np_app/service/index.html

5625 lines
232 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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);
}
.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;
}
.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;
}
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<div class="hero-top">
<div class="pill" id="statusBadge"></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>
</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>
</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>
</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="clear_history"></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-list" id="historyList"></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_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: "等待结果返回...",
submission_locked: "已有任务在等待结果返回,请等待完成;如需强制重新提交请刷新页面。",
example_btn: "载入示例",
history_title: "最近任务",
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_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 缺陷",
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_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: "Waiting for result...",
submission_locked: "A job is already waiting for results. Wait for it to finish, or refresh the page to submit again.",
example_btn: "Load Example",
history_title: "Recent Jobs",
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_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",
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 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 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 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 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 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;
runBtn.setAttribute("aria-busy", activeSubmission ? "true" : "false");
runBtn.textContent = activeSubmission ? t("run_waiting_btn") : runButtonBaseLabel();
}
function setSubmissionLocked(locked) {
activeSubmission = Boolean(locked);
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);
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);
});
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 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 || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`),
created_at: entry.created_at_iso || entry.created_at || new Date().toISOString(),
payload,
result,
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) {
const history = getHistory();
history.unshift({
id: `${Date.now()}`,
created_at: new Date().toISOString(),
payload,
result,
result_summary: buildHistorySummary(payload, {
workflow: result.workflow,
mode: result.mode,
complex_count: result.complexes.length,
compute: result.compute,
}),
});
setHistory(history);
}
function deleteHistory(id) {
setHistory(getHistory().filter((item) => item.id !== id));
renderHistory();
}
function exportHistory() {
downloadText("nupack-history.json", JSON.stringify({
version: 1,
exported_at: new Date().toISOString(),
history: getHistory(),
}, null, 2));
}
function exportHistoryItem(id) {
const item = getHistory().find((entry) => entry.id === id);
if (!item) return;
downloadText(`nupack-history-${id}.json`, JSON.stringify(item, null, 2));
}
async function importHistoryFiles(files) {
const imported = [];
for (const file of Array.from(files || [])) {
const text = await file.text();
imported.push(...extractHistoryEntries(JSON.parse(text)));
}
if (!imported.length) {
throw new Error("No valid history entries found.");
}
const existing = getHistory();
const byId = new Map();
[...imported, ...existing].forEach((item) => byId.set(item.id, item));
const merged = Array.from(byId.values()).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setHistory(merged);
renderHistory();
setStatus(formatStatus(`${t("history_import_success")}: ${imported.length}`));
}
async function copyTextToClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) throw new Error("Clipboard copy failed.");
}
async function shareHistoryItem(id) {
const item = getHistory().find((entry) => entry.id === id);
if (!item?.result) return;
const { response, data } = await fetchJsonOrThrow("/api/shares", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payload: item.payload,
result: item.result,
result_summary: item.result_summary,
}),
});
if (!response.ok || data.status !== "success") {
throw new Error(data?.error || `Share failed with HTTP ${response.status}`);
}
const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodeURIComponent(data.share_id)}`;
await copyTextToClipboard(shareUrl);
setStatus(formatStatus(`${t("history_share_success")}: ${shareUrl}`));
window.alert(`${t("history_share_copied")}\n${shareUrl}`);
}
function loadHistoryItem(id) {
const item = getHistory().find((entry) => entry.id === id);
if (!item) return;
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);
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 {
lastPayload = payload;
lastResult = null;
renderEmptyState();
}
}
async function loadSharedHistoryFromUrl() {
const shareId = new URLSearchParams(window.location.search).get("share");
if (!shareId) return;
try {
const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}`);
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.");
const merged = [item, ...getHistory().filter((entry) => entry.id !== item.id)]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setHistory(merged);
renderHistory();
loadHistoryItem(item.id);
setStatus(formatStatus(t("history_shared_loaded")));
} catch (error) {
setStatus(`${t("history_share_failed")}: ${error.message}`, true);
}
}
function renderHistory() {
const history = getHistory();
if (!history.length) {
historyList.innerHTML = `<div class="history-item"><p>${t("history_empty")}</p></div>`;
return;
}
historyList.innerHTML = history.map((item) => `
<div class="history-item">
<p>${new Date(item.created_at).toLocaleString()} · ${(item.result_summary.workflow || item.payload?.workflow || "analysis")} · ${t(`history_mode_${item.result_summary.mode}`)} · ${item.result_summary.complex_count} ${t("count_complexes")} · ${item.result ? t("history_has_result") : t("history_no_result")}</p>
<div class="compact-actions">
<button class="secondary" data-history-load="${item.id}" type="button">${t("history_load")}</button>
<button class="secondary" data-history-export="${item.id}" type="button">${t("history_export_one")}</button>
${item.result ? `<button class="secondary" data-history-share="${item.id}" type="button">${t("history_share")}</button>` : ""}
<button class="secondary" data-history-delete="${item.id}" type="button">${t("history_delete")}</button>
</div>
</div>
`).join("");
historyList.querySelectorAll("[data-history-load]").forEach((button) => {
button.addEventListener("click", () => loadHistoryItem(button.dataset.historyLoad));
});
historyList.querySelectorAll("[data-history-delete]").forEach((button) => {
button.addEventListener("click", () => deleteHistory(button.dataset.historyDelete));
});
historyList.querySelectorAll("[data-history-export]").forEach((button) => {
button.addEventListener("click", () => exportHistoryItem(button.dataset.historyExport));
});
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 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),
},
hard_constraints: getHardConstraints(),
soft_constraints: getSoftConstraints(),
defect_weights: getDefectWeights(),
};
}
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 || {};
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("summary_elapsed")}</strong><span class="metric-value">${stats.design_time ? `${Number(stats.design_time).toFixed(3)} s` : "-"}</span></div>
</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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));
});
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) {
const startedAt = Date.now();
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 === "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");
}
if (Date.now() - startedAt > 10 * 60 * 1000) {
throw new Error(t("request_timeout"));
}
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
async function runAnalysis() {
if (activeSubmission) {
setStatus(formatStatus(t("submission_locked")));
return;
}
const payload = buildPayload();
const validationError = validatePayload(payload);
if (validationError) {
results.innerHTML = `<div class="result-card"><h3>${t("request_failed")}</h3><div class="error-box">${validationError}</div></div>`;
setStatus(t("status_error"), true);
return;
}
setSubmissionLocked(true);
currentJobId = null;
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;
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) {
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);
}
}
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("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", () => {
localStorage.removeItem(HISTORY_KEY);
renderHistory();
});
document.getElementById("exportHistoryBtn").addEventListener("click", exportHistory);
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 = "";
}
});
runBtn.addEventListener("click", runAnalysis);
document.getElementById("exampleBtn").addEventListener("click", loadExample);
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();
});
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();
loadSharedHistoryFromUrl();
</script>
</body>
</html>