from __future__ import annotations
import argparse
import gzip
import json
from pathlib import Path
from typing import Any, Dict, Optional
def _resolve_path(path_text: Optional[str], json_path: Path) -> Optional[Path]:
if not path_text:
return None
path = Path(path_text)
candidates = [path]
if not path.is_absolute():
candidates.append(json_path.parent / path)
candidates.append(Path.cwd() / path)
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
return path
def _structure_format(path: Path) -> str:
suffix = path.with_suffix("").suffix.lower() if path.suffix.lower() == ".gz" else path.suffix.lower()
if suffix in {".cif", ".mmcif"}:
return "cif"
if suffix in {".pdb", ".ent"}:
return "pdb"
return suffix.lstrip(".") or "pdb"
def _read_structure_text(path: Path) -> str:
if path.suffix.lower() == ".gz":
with gzip.open(path, "rt", encoding="utf-8", errors="ignore") as handle:
return handle.read()
return path.read_text(encoding="utf-8", errors="ignore")
def _load_results(json_path: Path) -> Dict[str, Any]:
with json_path.open("r", encoding="utf-8") as handle:
return json.load(handle)
[docs]
def render_viewer_html(results: Dict[str, Any], structure_text: str, structure_format: str, title: str) -> str:
visualization = dict(results.get("visualization", {}))
parameters = dict(visualization.get("parameters", {}))
for name in (
"global_base_axis",
"global_base_pair_axis",
"curvesplus_base_pair_axis",
"global_base_base",
"global_inter_base",
"global_inter_base_pair",
"global_axis_curvature",
"global_axis_bending",
"global_axis_bending_summary",
"local_base_base",
"local_inter_base",
"local_inter_base_pair",
"backbone",
):
rows = results.get("dataframes", {}).get(name)
if isinstance(rows, list):
parameters[name] = rows
visualization["parameters"] = parameters
inputs = results.get("inputs", {})
summary = {
"program": results.get("program", "pyCurves"),
"format": results.get("format", ""),
"pdbfile": inputs.get("pdbfile", ""),
"inpfile": inputs.get("inpfile", ""),
"axis_points": len(visualization.get("axis", [])),
"base_pairs": len(visualization.get("base_pairs", [])),
"backbones": len(visualization.get("backbones", [])),
}
return HTML_TEMPLATE.replace("__TITLE_JSON__", json.dumps(title)).replace(
"__STRUCTURE_TEXT_JSON__", json.dumps(structure_text)
).replace(
"__STRUCTURE_FORMAT_JSON__", json.dumps(structure_format)
).replace(
"__VISUALIZATION_JSON__", json.dumps(visualization)
).replace(
"__SUMMARY_JSON__", json.dumps(summary)
)
[docs]
def write_viewer(json_file: str, output_file: Optional[str] = None, structure_file: Optional[str] = None) -> Path:
json_path = Path(json_file).resolve()
results = _load_results(json_path)
visualization = results.get("visualization")
if not visualization:
raise ValueError(
"This JSON file does not contain viewer geometry. "
"Regenerate it with: python pycurves.py <structure> --format json --visualization --output-file <file.json>"
)
inputs = results.get("inputs", {})
structure_path = _resolve_path(structure_file or inputs.get("pdbfile"), json_path)
if structure_path is None or not structure_path.exists():
raise FileNotFoundError(
"Could not find the source PDB/mmCIF file. Pass it explicitly with --structure."
)
structure_text = _read_structure_text(structure_path)
output_path = Path(output_file) if output_file else json_path.with_suffix(".viewer.html")
html = render_viewer_html(
results=results,
structure_text=structure_text,
structure_format=_structure_format(structure_path),
title=f"pyCurves viewer: {structure_path.name}",
)
output_path.write_text(html, encoding="utf-8")
return output_path.resolve()
def main() -> None:
from pycurves_lib import __version__
parser = argparse.ArgumentParser(description="Create an interactive HTML viewer from pyCurves JSON output.")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument("json_file", help="pyCurves JSON output file.")
parser.add_argument("-o", "--output", help="HTML file to write. Defaults to <json>.viewer.html.")
parser.add_argument("--structure", help="Override the structure file referenced in the pyCurves JSON.")
args = parser.parse_args()
output_path = write_viewer(args.json_file, output_file=args.output, structure_file=args.structure)
print(f"Wrote {output_path}")
HTML_TEMPLATE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<script src="https://3Dmol.org/build/3Dmol-min.js"></script>
<style>
:root {
color-scheme: light;
--panel: #f7f8fb;
--line: #d9dde8;
--text: #162033;
--muted: #627089;
--accent: #1266b0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
background: #ffffff;
}
.app {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
grid-template-rows: minmax(260px, 1fr) auto minmax(220px, 32vh);
height: 100vh;
}
aside {
grid-row: 1 / -1;
border-right: 1px solid var(--line);
background: var(--panel);
padding: 18px 16px;
overflow: auto;
}
main {
grid-column: 2;
grid-row: 1;
min-width: 0;
min-height: 0;
position: relative;
}
h1 {
margin: 0 0 14px;
font-size: 18px;
font-weight: 700;
letter-spacing: 0;
}
h2 {
margin: 22px 0 10px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0;
color: var(--muted);
}
.meta {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
font-size: 12px;
}
.meta div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
}
.meta span:first-child {
color: var(--muted);
}
.meta span:last-child {
overflow-wrap: anywhere;
}
label {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
font-size: 14px;
cursor: pointer;
}
input {
accent-color: var(--accent);
}
select {
width: 100%;
height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
color: var(--text);
font-size: 13px;
padding: 0 8px;
}
button {
width: 100%;
height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
color: var(--text);
font-size: 13px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
.view-actions {
display: flex;
margin: 14px 0;
}
.view-actions button {
width: auto;
padding: 0 12px;
}
.display-options {
margin-top: 14px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 10px 0;
}
.display-options summary {
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.option-grid {
display: grid;
gap: 2px;
padding-top: 8px;
}
.select-label {
display: block;
min-height: 0;
margin: 7px 0 4px;
color: var(--muted);
cursor: default;
font-size: 12px;
}
#viewer {
position: absolute;
inset: 0;
}
.load-error {
margin-top: 14px;
color: #9b1c1c;
font-size: 13px;
line-height: 1.4;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0;
}
.tabs button {
width: auto;
height: 30px;
font-size: 12px;
padding: 0 10px;
}
.tabs button.active {
border-color: #9fc4e5;
color: var(--accent);
background: #eaf3fb;
font-weight: 700;
}
.feature-groups {
display: flex;
min-width: 0;
flex: 1;
flex-wrap: wrap;
gap: 6px 18px;
}
.feature-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px 8px;
}
.feature-group > span {
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.sequence-panel {
grid-column: 2;
grid-row: 2;
min-width: 0;
padding: 8px 14px 9px;
border-top: 1px solid var(--line);
background: #ffffff;
}
.sequence-panel[hidden] {
display: none;
}
.sequence-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px 14px;
margin-bottom: 5px;
}
.sequence-head h2 {
margin: 0;
}
.sequence-summary {
flex: 1;
min-width: 220px;
color: var(--muted);
font-size: 12px;
}
.sequence-legend {
display: flex;
gap: 10px;
color: var(--muted);
font-size: 11px;
}
.legend-mark {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 4px;
border-radius: 50%;
}
.legend-mark.critical {
background: #c23b22;
}
.legend-mark.warning {
background: #c58a10;
}
.sequence-scroll {
overflow-x: auto;
padding: 2px 0 4px;
scrollbar-gutter: stable;
}
.sequence-row {
display: flex;
width: max-content;
min-width: 100%;
align-items: flex-end;
}
.sequence-strand {
position: sticky;
left: 0;
z-index: 2;
width: 70px;
flex: 0 0 70px;
padding: 0 7px 7px 0;
color: var(--muted);
background: #ffffff;
font-size: 10px;
text-align: right;
}
.sequence-base,
.sequence-gap {
width: 28px;
height: 38px;
flex: 0 0 28px;
}
.sequence-base {
position: relative;
border: 0;
border-bottom: 3px solid transparent;
border-radius: 4px;
padding: 11px 0 0;
color: var(--base-color);
background: transparent;
font-size: 17px;
font-weight: 700;
line-height: 22px;
}
.sequence-base::before {
content: attr(data-residue);
position: absolute;
top: 0;
left: 0;
width: 100%;
color: #8a93a3;
font-size: 9px;
font-weight: 400;
line-height: 11px;
}
.sequence-base:hover {
background: #f1f5f9;
}
.sequence-base.critical {
border-bottom-color: #c23b22;
background: #fff2ee;
}
.sequence-base.warning {
border-bottom-color: #c58a10;
background: #fff8e7;
}
.sequence-base.selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: #eaf3fb;
}
.sequence-controls {
display: flex;
align-items: center;
gap: 8px;
padding-left: 70px;
}
.sequence-controls input {
min-width: 160px;
flex: 1;
}
.sequence-controls output {
min-width: 58px;
color: var(--muted);
font-size: 11px;
text-align: right;
}
.parameters-panel {
grid-column: 2;
grid-row: 3;
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 8px;
padding: 10px 14px 12px;
border-top: 1px solid var(--line);
background: var(--panel);
}
.parameters-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
}
.parameters-head h2 {
margin: 0;
}
.table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
scrollbar-gutter: stable both-edges;
}
table {
min-width: 720px;
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th,
td {
padding: 6px 7px;
border-bottom: 1px solid #edf0f5;
text-align: right;
white-space: nowrap;
}
th:last-child,
td:last-child {
padding-right: 24px;
}
th {
position: sticky;
top: 0;
z-index: 2;
background: #f7f8fb;
}
th:first-child,
td:first-child {
text-align: left;
position: sticky;
left: 0;
z-index: 1;
background: #ffffff;
box-shadow: 1px 0 0 #edf0f5;
}
th:first-child {
z-index: 3;
background: #f7f8fb;
}
tr {
cursor: pointer;
}
tr:hover,
tr.selected {
background: #e9f3ff;
}
td.selected-feature {
background: #ffd79a;
color: #111827;
font-weight: 700;
}
.annotation-badge {
display: inline-block;
min-width: 28px;
margin-right: 6px;
padding: 1px 4px;
border-radius: 999px;
color: #ffffff;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
vertical-align: 1px;
}
.annotation-badge.critical {
background: #c23b22;
}
.annotation-badge.warning {
background: #c58a10;
}
.empty {
padding: 10px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 820px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(420px, 55vh) auto minmax(240px, 35vh);
height: auto;
min-height: 100vh;
}
aside {
grid-column: 1;
grid-row: 1;
border-right: 0;
border-bottom: 1px solid var(--line);
}
main {
grid-column: 1;
grid-row: 2;
}
.sequence-panel {
grid-column: 1;
grid-row: 3;
}
.parameters-panel {
grid-column: 1;
grid-row: 4;
}
}
</style>
</head>
<body>
<div class="app">
<aside>
<h1 id="title"></h1>
<div class="meta" id="meta"></div>
<div class="view-actions">
<button id="resetView" type="button">Reset view</button>
</div>
<details class="display-options">
<summary>Display options</summary>
<div class="option-grid">
<label><input type="checkbox" id="showOtherChains" checked> Show chains other than DNA</label>
<label><input type="checkbox" id="showCartoon" checked> Cartoon</label>
<label><input type="checkbox" id="showAllAtoms"> All atoms</label>
<label class="select-label" for="colorMode">Molecule color</label>
<select id="colorMode">
<option value="chain">By chain</option>
<option value="residue">By residue</option>
<option value="element">By element</option>
</select>
<label><input type="checkbox" id="showAxis" checked> Smoothed axis</label>
<label><input type="checkbox" id="showBackbone" checked> Backbone spline curve</label>
<label><input type="checkbox" id="showActualBlocks" checked> Actual base blocks</label>
<label><input type="checkbox" id="showAnalyticalBases"> Analytical base frames</label>
<label><input type="checkbox" id="showLabels"> Labels</label>
<label><input type="checkbox" id="showOnlyUnusual"> Only unusual pairs</label>
</div>
</details>
<p class="load-error" id="loadError" hidden></p>
</aside>
<main>
<div id="viewer"></div>
</main>
<section class="sequence-panel" aria-labelledby="sequenceTitle">
<div class="sequence-head">
<h2 id="sequenceTitle">DNA sequence</h2>
<div class="sequence-summary" id="sequenceSummary">Choose a base to inspect it in 3D.</div>
<div class="sequence-legend" aria-label="Annotation legend">
<span><i class="legend-mark critical"></i>Critical</span>
<span><i class="legend-mark warning"></i>Review</span>
</div>
</div>
<div class="sequence-scroll" id="sequenceViewer"></div>
<div class="sequence-controls">
<input id="sequenceSlider" type="range" aria-label="DNA sequence level">
<output id="sequenceLevel" for="sequenceSlider"></output>
</div>
</section>
<section class="parameters-panel" aria-labelledby="parametersTitle">
<div class="parameters-head">
<h2 id="parametersTitle">DNA shape</h2>
<div class="feature-groups">
<div class="feature-group" data-feature-group="global">
<span>Global features</span>
<div class="tabs">
<button type="button" data-tab="global_base_axis">Base Axis</button>
<button type="button" data-tab="global_base_pair_axis">BP Axis</button>
<button type="button" data-tab="global_base_pair">Base Pair</button>
<button type="button" data-tab="global_strand_step">Strand Step</button>
<button type="button" data-tab="global_base_pair_step">BP Step</button>
<button type="button" data-tab="global_axis_curvature">Axis Curvature</button>
<button type="button" data-tab="global_axis_bending">Axis Bending</button>
<button type="button" data-tab="global_axis_bending_summary">Bending Summary</button>
</div>
</div>
<div class="feature-group" data-feature-group="local">
<span>Local features</span>
<div class="tabs">
<button type="button" data-tab="local_base_pair">Base Pair</button>
<button type="button" data-tab="local_strand_step">Strand Step</button>
<button type="button" data-tab="local_base_pair_step">BP Step</button>
<button type="button" data-tab="backbone">Backbone</button>
<button type="button" data-tab="groove">Groove</button>
</div>
</div>
</div>
</div>
<div class="table-wrap" id="parameterTable"></div>
</section>
</div>
<script>
const PAGE_TITLE = __TITLE_JSON__;
const STRUCTURE_TEXT = __STRUCTURE_TEXT_JSON__;
const STRUCTURE_FORMAT = __STRUCTURE_FORMAT_JSON__;
const VIS = __VISUALIZATION_JSON__;
const SUMMARY = __SUMMARY_JSON__;
document.title = PAGE_TITLE;
document.getElementById("title").textContent = PAGE_TITLE;
document.getElementById("meta").innerHTML = [
["Structure", SUMMARY.pdbfile || "(embedded)"],
["Input", SUMMARY.inpfile || ""],
["Axis points", SUMMARY.axis_points],
["Base pairs", SUMMARY.base_pairs],
["Backbone curves", SUMMARY.backbones]
].map(([key, value]) => `<div><span>${key}</span><span>${value}</span></div>`).join("");
let viewer = null;
let overlayShapes = [];
let overlayLabels = [];
let selectionShapes = [];
let selectionLabels = [];
let drawingSelection = false;
const parameterTabOrder = [
"global_base_axis", "global_base_pair_axis", "global_base_pair",
"global_strand_step", "global_base_pair_step", "global_axis_curvature",
"global_axis_bending", "global_axis_bending_summary", "local_base_pair",
"local_strand_step", "local_base_pair_step", "backbone", "groove"
];
function initialParameterTab() {
return parameterTabOrder.find(tab => tableRowsForTab(tab).length) || "global_base_pair";
}
let activeTab = initialParameterTab();
let selectedInspection = null;
const strandColors = ["#1b74b7", "#c03a2b", "#2d8a4e", "#8b5cc7", "#b6801d", "#008c95"];
const baseColors = {
A: "#2e9d57",
C: "#2369b3",
G: "#f0b72f",
T: "#d64b3f",
U: "#8a5bb8",
I: "#8a8f98"
};
const proteinResidueColors = {
ALA: "#9aa1aa", VAL: "#9aa1aa", LEU: "#9aa1aa", ILE: "#9aa1aa", MET: "#9aa1aa", PRO: "#9aa1aa",
PHE: "#b28b2c", TYR: "#b28b2c", TRP: "#b28b2c", HIS: "#b28b2c",
SER: "#2c9a9a", THR: "#2c9a9a", ASN: "#2c9a9a", GLN: "#2c9a9a", CYS: "#2c9a9a",
LYS: "#3b68b8", ARG: "#3b68b8",
ASP: "#c54b45", GLU: "#c54b45",
GLY: "#6f7784"
};
function colorForChain(chain, index) {
return strandColors[index % strandColors.length];
}
function xyz(point) {
if (Array.isArray(point)) return {x: Number(point[0]), y: Number(point[1]), z: Number(point[2])};
return {x: Number(point.x), y: Number(point.y), z: Number(point.z)};
}
function vec(point) {
if (Array.isArray(point)) return [Number(point[0]), Number(point[1]), Number(point[2])];
return [Number(point.x), Number(point.y), Number(point.z)];
}
function pointFrom(values) {
return {x: values[0], y: values[1], z: values[2]};
}
function addVec(left, right) {
return [left[0] + right[0], left[1] + right[1], left[2] + right[2]];
}
function subVec(left, right) {
return [left[0] - right[0], left[1] - right[1], left[2] - right[2]];
}
function scaleVec(values, scale) {
return [values[0] * scale, values[1] * scale, values[2] * scale];
}
function crossVec(left, right) {
return [
left[1] * right[2] - left[2] * right[1],
left[2] * right[0] - left[0] * right[2],
left[0] * right[1] - left[1] * right[0]
];
}
function normVec(values) {
return Math.hypot(values[0], values[1], values[2]);
}
function unitVec(values, fallback = [1, 0, 0]) {
const length = normVec(values);
if (!Number.isFinite(length) || length < 1e-6) return fallback;
return scaleVec(values, 1 / length);
}
function dotVec(left, right) {
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
}
function alignVec(values, reference) {
return dotVec(values, reference) < 0 ? scaleVec(values, -1) : values;
}
function addShape(shape) {
(drawingSelection ? selectionShapes : overlayShapes).push(shape);
return shape;
}
function addLabel(label) {
(drawingSelection ? selectionLabels : overlayLabels).push(label);
return label;
}
function addCurve(points, color, radius, opacity = 1.0) {
if (!points || points.length < 2) return;
addShape(viewer.addCurve({
points: points.map(xyz),
radius,
color,
opacity,
smooth: 1
}));
}
function addSegment(a, b, color, radius, opacity = 1.0) {
if (!a || !b) return;
addShape(viewer.addCylinder({
start: xyz(a),
end: xyz(b),
radius,
color,
opacity,
fromCap: 1,
toCap: 1
}));
}
function addDashedSegment(a, b, color, radius, segments = 9, opacity = 1.0) {
if (!a || !b) return;
const start = vec(a);
const delta = subVec(vec(b), start);
for (let i = 0; i < segments; i += 2) {
const t1 = i / segments;
const t2 = Math.min((i + 1) / segments, 1.0);
addSegment(
pointFrom(addVec(start, scaleVec(delta, t1))),
pointFrom(addVec(start, scaleVec(delta, t2))),
color,
radius,
opacity
);
}
}
function addPoint(point, color, radius) {
if (!point) return;
addShape(viewer.addSphere({center: xyz(point), radius, color}));
}
function addArrowLikeSegment(a, b, color, radius, opacity = 1.0) {
if (!a || !b) return;
addSegment(a, b, color, radius, opacity);
const direction = unitVec(subVec(vec(b), vec(a)), [1, 0, 0]);
const end = vec(b);
const fallback = Math.abs(direction[2]) < 0.82 ? [0, 0, 1] : [0, 1, 0];
const side1 = unitVec(crossVec(direction, fallback), [0, 1, 0]);
const side2 = unitVec(crossVec(direction, side1), [0, 0, 1]);
const headLength = Math.max(radius * 8.0, 0.22);
const headWidth = Math.max(radius * 4.2, 0.12);
const back = subVec(end, scaleVec(direction, headLength));
addSegment(b, pointFrom(addVec(back, scaleVec(side1, headWidth))), color, radius, opacity);
addSegment(b, pointFrom(subVec(back, scaleVec(side1, headWidth))), color, radius, opacity);
addSegment(b, pointFrom(addVec(back, scaleVec(side2, headWidth))), color, radius, opacity);
addSegment(b, pointFrom(subVec(back, scaleVec(side2, headWidth))), color, radius, opacity);
}
function addFilledQuad(corners, color, opacity = 0.7) {
if (viewer.addCustom && window.$3Dmol && window.$3Dmol.Vector3) {
try {
return addShape(viewer.addCustom({
vertexArr: corners.map(point => new $3Dmol.Vector3(point.x, point.y, point.z)),
faceArr: [0, 1, 2, 0, 2, 3],
normalArr: [],
color,
opacity
}));
} catch (error) {
// Fall back to a solid-looking strip fill below.
}
}
const strips = 11;
for (let i = 0; i < strips; i += 1) {
const t = strips === 1 ? 0.5 : i / (strips - 1);
const left = pointFrom(addVec(scaleVec(vec(corners[0]), 1 - t), scaleVec(vec(corners[3]), t)));
const right = pointFrom(addVec(scaleVec(vec(corners[1]), 1 - t), scaleVec(vec(corners[2]), t)));
addSegment(left, right, color, 0.12, opacity);
}
}
function baseColor(base) {
const key = String(base || "").trim().toUpperCase().replace(/^D/, "").slice(0, 1);
return baseColors[key] || "#8a8f98";
}
function residueColor(residueName) {
const raw = String(residueName || "").trim().toUpperCase();
const baseKey = raw.replace(/^D/, "").slice(0, 1);
if (baseColors[baseKey] && ["A", "C", "G", "T", "U", "I"].includes(baseKey)) {
return baseColors[baseKey];
}
return proteinResidueColors[raw] || "#8a8f98";
}
function pairColor(pair) {
if (pair.is_hoogsteen) return "#bb3bbf";
if (pair.is_mismatch) return "#d97904";
if (pair.has_modified_base) return "#008c95";
if (!pair.shape_parameters_supported) return "#b22b2b";
return pair.is_canonical ? "#2d8a4e" : "#6e7687";
}
function basePlateCorners(base, partner) {
const center = vec(base);
let longAxis = base.plate_x_axis ? unitVec(vec(base.plate_x_axis)) : null;
let shortAxis = base.plate_y_axis ? unitVec(vec(base.plate_y_axis)) : null;
if (!longAxis || !shortAxis) {
const toPartner = partner ? unitVec(subVec(vec(partner), center)) : [1, 0, 0];
const zAxis = [0, 0, 1];
shortAxis = unitVec(crossVec(toPartner, zAxis), [0, 1, 0]);
longAxis = unitVec(crossVec(shortAxis, toPartner), [1, 0, 0]);
}
shortAxis = unitVec(subVec(shortAxis, scaleVec(longAxis, shortAxis[0] * longAxis[0] + shortAxis[1] * longAxis[1] + shortAxis[2] * longAxis[2])), shortAxis);
if (partner) {
const toPartner = unitVec(subVec(vec(partner), center));
if (longAxis[0] * toPartner[0] + longAxis[1] * toPartner[1] + longAxis[2] * toPartner[2] < 0) {
longAxis = scaleVec(longAxis, -1);
}
}
const halfLong = scaleVec(longAxis, Number(base.plate_length || 3.6) / 2);
const halfShort = scaleVec(shortAxis, Number(base.plate_width || 1.45) / 2);
return [
pointFrom(addVec(addVec(center, halfLong), halfShort)),
pointFrom(addVec(subVec(center, halfLong), halfShort)),
pointFrom(subVec(subVec(center, halfLong), halfShort)),
pointFrom(addVec(subVec(center, halfShort), halfLong))
];
}
function analyticalBasePlate(base) {
const origin = base.frame_origin || base;
const contactFrame = String(base.analysis_frame_source || "") === "contact_geometry";
return Object.assign({}, base, {
x: origin.x,
y: origin.y,
z: origin.z,
plate_x_axis: base.x_axis || base.plate_x_axis || base.y_axis,
plate_y_axis: base.y_axis || base.plate_y_axis || base.x_axis,
plate_z_axis: base.z_axis || base.plate_z_axis,
plate_length: contactFrame ? 2.2 : Number(base.plate_length || 5.0) * 0.58,
plate_width: contactFrame ? 1.1 : Number(base.plate_width || 2.7) * 0.58
});
}
function actualBlockForPair(base, pair) {
if (!pair.is_hoogsteen || !base.hoogsteen_plate_x_axis) return base;
return Object.assign({}, base, {
plate_x_axis: base.hoogsteen_plate_x_axis,
plate_y_axis: base.hoogsteen_plate_y_axis,
plate_z_axis: base.hoogsteen_plate_z_axis,
plate_length: base.hoogsteen_plate_length || base.plate_length,
plate_width: base.hoogsteen_plate_width || base.plate_width,
hbond_edge_center: base.hoogsteen_hbond_edge_center || base.hbond_edge_center,
hbond_edge_atoms: base.hoogsteen_hbond_edge_atoms || base.hbond_edge_atoms
});
}
function drawBasePlate(base, partner, outlineColor, options = {}) {
if (!base) return;
const color = baseColor(base.parent_base || base.residue_name);
const corners = basePlateCorners(base, partner);
const fillOpacity = options.fillOpacity ?? 0.68;
const edgeRadius = options.edgeRadius ?? 0.075;
const edgeOpacity = options.edgeOpacity ?? 0.95;
const fillColor = options.fillColor || color;
addFilledQuad(corners, fillColor, fillOpacity);
if (options.outline !== false) {
addSegment(corners[0], corners[1], outlineColor, edgeRadius, edgeOpacity);
addSegment(corners[1], corners[2], outlineColor, edgeRadius, edgeOpacity);
addSegment(corners[2], corners[3], outlineColor, edgeRadius, edgeOpacity);
addSegment(corners[3], corners[0], outlineColor, edgeRadius, edgeOpacity);
if (partner && options.highlightFacingEdge !== false) {
addSegment(corners[3], corners[0], outlineColor, edgeRadius * 1.9, 1.0);
}
}
}
function drawFrameAxes(base, scale = 1.15) {
if (!base) return;
const origin = base.frame_origin || base;
const xAxis = unitVec(vec(base.x_axis || {x: 1, y: 0, z: 0}), [1, 0, 0]);
const yAxis = unitVec(vec(base.y_axis || {x: 0, y: 1, z: 0}), [0, 1, 0]);
const zAxis = unitVec(vec(base.z_axis || {x: 0, y: 0, z: 1}), [0, 0, 1]);
addPoint(origin, "#151a24", 0.09);
addSegment(origin, pointFrom(addVec(vec(origin), scaleVec(xAxis, scale))), "#d22f2f", 0.035, 0.9);
addSegment(origin, pointFrom(addVec(vec(origin), scaleVec(yAxis, scale))), "#268a3e", 0.035, 0.9);
addSegment(origin, pointFrom(addVec(vec(origin), scaleVec(zAxis, scale))), "#2866d8", 0.035, 0.9);
}
function clampNumber(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function framePoint(frame, axisName, distance) {
return pointFrom(addVec(vec(frame.origin), scaleVec(frame[axisName], distance)));
}
function baseAnalysisFrame(base) {
if (!base) return null;
const analytical = analyticalBasePlate(base);
return {
origin: analytical.frame_origin || analytical,
x: unitVec(vec(analytical.x_axis || {x: 1, y: 0, z: 0}), [1, 0, 0]),
y: unitVec(vec(analytical.y_axis || {x: 0, y: 1, z: 0}), [0, 1, 0]),
z: unitVec(vec(analytical.z_axis || {x: 0, y: 0, z: 1}), [0, 0, 1])
};
}
function averageFrames(first, second, origin = null) {
if (!first) return second;
if (!second) return first;
const x2 = alignVec(second.x, first.x);
const y2 = alignVec(second.y, first.y);
const z2 = alignVec(second.z, first.z);
const xAxis = unitVec(addVec(first.x, x2), first.x);
let yAxis = unitVec(addVec(first.y, y2), first.y);
yAxis = unitVec(subVec(yAxis, scaleVec(xAxis, dotVec(yAxis, xAxis))), first.y);
const zPreferred = unitVec(addVec(first.z, z2), first.z);
let zAxis = unitVec(crossVec(xAxis, yAxis), zPreferred);
if (dotVec(zAxis, zPreferred) < 0) {
yAxis = scaleVec(yAxis, -1);
zAxis = scaleVec(zAxis, -1);
}
const frameOrigin = origin || pointFrom(scaleVec(addVec(vec(first.origin), vec(second.origin)), 0.5));
return {origin: frameOrigin, x: xAxis, y: yAxis, z: zAxis};
}
function pairFrame(pair) {
if (!pair) return null;
const first = baseAnalysisFrame(pair.first);
const second = baseAnalysisFrame(pair.second);
const origin = pair.frame_midpoint || pair.midpoint || null;
return averageFrames(first, second, origin);
}
function drawFrameGlyph(frame, scale = 1.35, color = "#111827") {
if (!frame) return;
addPoint(frame.origin, color, 0.11);
addSegment(frame.origin, framePoint(frame, "x", scale), "#d22f2f", 0.045, 0.95);
addSegment(frame.origin, framePoint(frame, "y", scale), "#268a3e", 0.045, 0.95);
addSegment(frame.origin, framePoint(frame, "z", scale), "#2866d8", 0.045, 0.95);
}
function featureLabel(feature) {
const labels = {
shear: "Shear", stretch: "Stretch", stagger: "Stagger",
buckle: "Buckle", propel: "Propel", opening: "Opening",
xdisp: "Xdisp", ydisp: "Ydisp", inclin: "Inclin", tip: "Tip",
shift: "Shift", slide: "Slide", rise: "Rise",
tilt: "Tilt", roll: "Roll", twist: "Twist",
minor_width: "Minor width", minor_depth: "Minor depth", minor_angle: "Minor angle",
major_width: "Major width", major_depth: "Major depth", major_angle: "Major angle",
diameter: "Diameter"
};
return labels[feature] || feature || "Feature";
}
function featureUnit(feature) {
if (["buckle", "propel", "opening", "inclin", "tip", "tilt", "roll", "twist", "minor_angle", "major_angle"].includes(feature)) return "deg";
if (["shear", "stretch", "stagger", "xdisp", "ydisp", "shift", "slide", "rise", "minor_width", "minor_depth", "major_width", "major_depth", "diameter"].includes(feature)) return "A";
return "";
}
function translationAxisForFeature(feature) {
return {
shear: "x", stretch: "y", stagger: "z",
xdisp: "x", ydisp: "y",
shift: "x", slide: "y", rise: "z"
}[feature] || null;
}
function rotationAxisForFeature(feature) {
return {
buckle: "x", propel: "y", opening: "z",
inclin: "x", tip: "y",
tilt: "x", roll: "y", twist: "z"
}[feature] || null;
}
function drawAxisGuide(frame, axisName, color) {
addDashedSegment(framePoint(frame, axisName, -1.75), framePoint(frame, axisName, 1.75), color, 0.035, 10, 0.55);
}
function drawTranslationFeature(frame, feature, value, color) {
const axisName = translationAxisForFeature(feature);
if (!frame || !axisName) return false;
const numeric = Number(value);
drawAxisGuide(frame, axisName, color);
const sign = Number.isFinite(numeric) && numeric < 0 ? -1 : 1;
const distance = sign * clampNumber(Number.isFinite(numeric) ? Math.abs(numeric) : 1.2, 0.75, 3.2);
const end = framePoint(frame, axisName, distance);
addArrowLikeSegment(frame.origin, end, color, 0.09, 0.96);
const unit = featureUnit(feature);
addValueLabel(end, `${featureLabel(feature)} ${fmt(value)}${unit ? " " + unit : ""}`, color);
return true;
}
function drawRotationFeature(frame, feature, value, color) {
const axisName = rotationAxisForFeature(feature);
if (!frame || !axisName) return false;
const numeric = Number(value);
drawAxisGuide(frame, axisName, color);
const axis = frame[axisName];
const startAxisName = axisName === "x" ? "y" : "x";
const startDirection = unitVec(subVec(frame[startAxisName], scaleVec(axis, dotVec(frame[startAxisName], axis))), [1, 0, 0]);
const turnDirection = unitVec(crossVec(axis, startDirection), [0, 1, 0]);
const sign = Number.isFinite(numeric) && numeric < 0 ? -1 : 1;
const sweepDegrees = clampNumber(Number.isFinite(numeric) ? Math.abs(numeric) : 45.0, 18.0, 125.0);
const sweep = sign * sweepDegrees * Math.PI / 180.0;
const radius = 1.25;
const steps = 14;
let previous = null;
let current = null;
for (let i = 0; i <= steps; i += 1) {
const theta = sweep * i / steps;
const direction = addVec(scaleVec(startDirection, Math.cos(theta)), scaleVec(turnDirection, Math.sin(theta)));
current = pointFrom(addVec(vec(frame.origin), scaleVec(direction, radius)));
if (previous) {
if (i === steps) addArrowLikeSegment(previous, current, color, 0.055, 0.96);
else addSegment(previous, current, color, 0.055, 0.96);
}
previous = current;
}
const unit = featureUnit(feature);
addValueLabel(current, `${featureLabel(feature)} ${fmt(value)}${unit ? " " + unit : ""}`, color);
return true;
}
function drawFeatureMeasurement(frame, feature, value, color) {
if (!feature) return false;
return drawTranslationFeature(frame, feature, value, color) || drawRotationFeature(frame, feature, value, color);
}
function drawAnalyticalPairSource(pair, color) {
if (!pair) return;
const first = analyticalBasePlate(pair.first);
const second = analyticalBasePlate(pair.second);
drawFrameAxes(first, first.analysis_frame_source === "contact_geometry" ? 1.35 : 1.1);
drawFrameAxes(second, second.analysis_frame_source === "contact_geometry" ? 1.35 : 1.1);
const firstOrigin = first.frame_origin || first;
const secondOrigin = second.frame_origin || second;
addDashedSegment(firstOrigin, secondOrigin, color, 0.045, 9, 0.85);
}
function fmt(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
if (typeof value === "number") return value.toFixed(2);
return String(value);
}
function pairAtLevel(level, partnerStrand = null) {
return (VIS.base_pairs || []).find(pair => {
if (Number(pair.level) !== Number(level)) return false;
if (partnerStrand === null || partnerStrand === undefined || Number(partnerStrand) === 0) return true;
return Number(pair.first && pair.first.strand || 0) === Number(partnerStrand)
|| Number(pair.second && pair.second.strand || 0) === Number(partnerStrand);
});
}
function baseAtLevel(strand, level) {
return (VIS.base_origins || []).find(base => Number(base.level) === Number(level) && Number(base.strand || 0) === Number(strand));
}
function axisAtLevel(level, strand = null) {
const points = VIS.axis || [];
if (strand !== null && strand !== undefined) {
const matched = points.find(point => Number(point.level) === Number(level) && Number(point.strand || 0) === Number(strand));
if (matched) return matched;
}
return points.find(point => Number(point.level) === Number(level));
}
function addValueLabel(point, text, color = "#151a24") {
if (!point || !text) return;
addLabel(viewer.addLabel(text, {
position: xyz(point),
fontSize: 11,
fontColor: color,
backgroundColor: "#ffffff",
backgroundOpacity: 0.82,
borderThickness: 0.5,
borderColor: color
}));
}
function isUnusual(pair) {
return Boolean(pairAnnotation(pair));
}
function pairAnnotation(pair) {
if (!pair) return null;
const observed = String(pair.observed_lw_family || "").trim();
const family = String(pair.pair_family || "").trim().toLowerCase();
const mode = String(pair.pairing_mode || "").trim().toLowerCase();
const status = String(pair.pair_status || "present").trim().toLowerCase();
if (pair.has_modified_base) {
return {kind: "critical", label: "Modified", detail: "Modified base in pair"};
}
const namedModes = {
reverse_watson_crick: ["Reverse WC", "Reverse Watson-Crick base pair"],
hoogsteen: ["Hoogsteen", "Hoogsteen base pair"],
reverse_hoogsteen: ["Reverse Hoogsteen", "Reverse Hoogsteen base pair"],
wobble: ["Wobble", "Wobble base pair"]
};
if (namedModes[mode]) {
return {kind: "critical", label: namedModes[mode][0], detail: namedModes[mode][1]};
}
if (mode === "other_noncanonical") {
return {kind: "critical", label: observed || "Noncanonical", detail: `Other noncanonical base pair${observed ? ": " + observed : ""}`};
}
if (pair.is_hoogsteen || family.includes("hoogsteen")) {
return {kind: "critical", label: "Hoogsteen", detail: "Hoogsteen base pair"};
}
if (family === "wobble") {
return {kind: "critical", label: "Wobble", detail: "Wobble base pair"};
}
if (pair.is_mismatch || family === "mismatch") {
return {kind: "critical", label: "Mismatch", detail: "Mismatched base pair"};
}
if ((observed && observed.toLowerCase() !== "cww") || family.includes("noncanonical")) {
const label = observed && observed.toLowerCase() !== "cww" ? observed : "Non-WC";
return {kind: "critical", label, detail: `Non-canonical contact${observed ? ": " + observed : ""}`};
}
if (!pair.shape_parameters_supported) {
return {kind: "critical", label: "No shape", detail: "Shape parameters are unsupported for this pair"};
}
if (status !== "present") {
return {kind: "warning", label: status || "Review", detail: `Pair status: ${status || "uncertain"}`};
}
const flags = (pair.diagnostic_flags || []).filter(Boolean);
if (flags.length) {
return {kind: "warning", label: "Review", detail: flags.map(flag => String(flag).replaceAll("_", " ")).join("; ")};
}
return null;
}
function highlightSequenceLevel(level) {
const numericLevel = Number(level);
document.querySelectorAll(".sequence-base.selected").forEach(base => base.classList.remove("selected"));
const selectedBases = document.querySelectorAll(`.sequence-base[data-level="${numericLevel}"]`);
selectedBases.forEach(base => base.classList.add("selected"));
if (selectedBases.length) selectedBases[0].scrollIntoView({block: "nearest", inline: "center"});
const slider = document.getElementById("sequenceSlider");
slider.value = numericLevel;
document.getElementById("sequenceLevel").value = `Level ${numericLevel}`;
const pair = pairAtLevel(numericLevel);
const annotation = pairAnnotation(pair);
const bases = (VIS.base_origins || []).filter(base => Number(base.level) === numericLevel);
const labels = bases.map(base => `${base.chain_id || "S" + base.strand}:${base.parent_base || base.residue_name}${base.residue_id}`).join(" / ");
document.getElementById("sequenceSummary").textContent = pair
? `Level ${numericLevel} · ${pair.label || labels}${annotation ? " · " + annotation.detail : ""}`
: `Level ${numericLevel} · ${labels || "no base"} · no inferred base pair`;
}
function selectSequenceLevel(level, focusViewer = false) {
const numericLevel = Number(level);
highlightSequenceLevel(numericLevel);
const pair = pairAtLevel(numericLevel);
activeTab = tableRowsForTab("global_base_pair").length ? "global_base_pair" : "local_base_pair";
document.querySelectorAll(".tabs button").forEach(button => {
button.classList.toggle("active", button.dataset.tab === activeTab);
});
const entry = tableRowsForTab(activeTab).find(row => Number(row.level) === numericLevel);
selectedInspection = entry
? Object.assign({}, entry, {type: pair ? "base_pair" : "sequence", feature: null, featureLabel: null, featureValue: null})
: {id: `sequence:${numericLevel}`, type: "sequence", level: numericLevel};
renderParameterTable();
redrawSelection();
const tableRow = document.querySelector("#parameterTable tbody tr.selected");
if (tableRow) tableRow.scrollIntoView({block: "nearest"});
if (focusViewer && viewer) {
const bases = pair
? [pair.first, pair.second]
: (VIS.base_origins || []).filter(base => Number(base.level) === numericLevel);
const selections = bases.filter(Boolean).map(base => {
const selection = {resi: Number(base.residue_id)};
if (base.chain_id) selection.chain = base.chain_id;
return selection;
});
if (selections.length) {
viewer.center({or: selections}, 250);
viewer.render();
}
}
}
function renderSequenceNavigator() {
const bases = VIS.base_origins || [];
const panel = document.querySelector(".sequence-panel");
if (!bases.length) {
panel.hidden = true;
return;
}
const groups = new Map();
bases.forEach(base => {
const strand = Number(base.strand || 0);
if (!groups.has(strand)) groups.set(strand, new Map());
groups.get(strand).set(Number(base.level), base);
});
const levels = bases.map(base => Number(base.level));
const minLevel = Math.min(...levels);
const maxLevel = Math.max(...levels);
const pairByLevel = new Map((VIS.base_pairs || []).map(pair => [Number(pair.level), pair]));
const container = document.getElementById("sequenceViewer");
container.replaceChildren();
Array.from(groups.entries()).sort((left, right) => left[0] - right[0]).forEach(([strand, basesByLevel], index) => {
const row = document.createElement("div");
row.className = "sequence-row";
const strandLabel = document.createElement("span");
strandLabel.className = "sequence-strand";
strandLabel.textContent = `S${strand} ${index === 0 ? "5′→3′" : "3′→5′"}`;
row.appendChild(strandLabel);
for (let level = minLevel; level <= maxLevel; level += 1) {
const base = basesByLevel.get(level);
if (!base) {
const gap = document.createElement("span");
gap.className = "sequence-gap";
row.appendChild(gap);
continue;
}
const button = document.createElement("button");
const pair = pairByLevel.get(level);
const annotation = pair ? pairAnnotation(pair) : (groups.size > 1 ? {kind: "warning", detail: "No inferred base pair"} : null);
const letter = String(base.parent_base || base.residue_name || "?").replace(/^D/i, "").slice(0, 1).toUpperCase();
button.type = "button";
button.className = `sequence-base${annotation ? " " + annotation.kind : ""}`;
button.dataset.level = level;
button.dataset.residue = base.residue_id;
button.style.setProperty("--base-color", baseColor(letter));
button.textContent = letter;
button.title = `${base.chain_id || "Strand " + strand}:${base.residue_name}${base.residue_id} · level ${level}${annotation ? " · " + annotation.detail : ""}`;
button.setAttribute("aria-label", button.title);
button.addEventListener("click", () => selectSequenceLevel(level, true));
row.appendChild(button);
}
container.appendChild(row);
});
const slider = document.getElementById("sequenceSlider");
slider.min = minLevel;
slider.max = maxLevel;
slider.step = 1;
slider.value = minLevel;
slider.addEventListener("input", event => highlightSequenceLevel(event.target.value));
slider.addEventListener("change", event => selectSequenceLevel(event.target.value, true));
highlightSequenceLevel(minLevel);
}
function clearOverlays() {
overlayShapes.forEach(shape => viewer.removeShape(shape));
overlayLabels.forEach(label => viewer.removeLabel(label));
selectionShapes.forEach(shape => viewer.removeShape(shape));
selectionLabels.forEach(label => viewer.removeLabel(label));
overlayShapes = [];
overlayLabels = [];
selectionShapes = [];
selectionLabels = [];
}
function clearSelection() {
selectionShapes.forEach(shape => viewer.removeShape(shape));
selectionLabels.forEach(label => viewer.removeLabel(label));
selectionShapes = [];
selectionLabels = [];
}
function drawStructure() {
viewer.setStyle({}, {});
const colorMode = document.getElementById("colorMode").value;
const baseStyle = {};
if (document.getElementById("showCartoon").checked) {
baseStyle.cartoon = {opacity: 0.58};
}
if (document.getElementById("showAllAtoms").checked) {
baseStyle.stick = {radius: 0.11};
}
if (!baseStyle.cartoon && !baseStyle.stick) return;
const analyzedNames = VIS.analyzed_residue_names || [];
const useAnalyzedOnly = !document.getElementById("showOtherChains").checked && analyzedNames.length > 0;
const baseSelection = useAnalyzedOnly ? {resn: analyzedNames} : {};
function styleWith(color) {
const style = JSON.parse(JSON.stringify(baseStyle));
if (style.cartoon) style.cartoon.color = color;
if (style.stick) {
if (colorMode === "element") style.stick.colorscheme = "Jmol";
else style.stick.color = color;
}
return style;
}
if (colorMode === "element") {
const style = JSON.parse(JSON.stringify(baseStyle));
if (style.cartoon) style.cartoon.colorscheme = "chainHetatm";
if (style.stick) style.stick.colorscheme = "Jmol";
viewer.setStyle(baseSelection, style);
return;
}
if (colorMode === "chain") {
const chains = VIS.structure_chains || [];
if (chains.length === 0) {
viewer.setStyle(baseSelection, styleWith("#8a8f98"));
return;
}
chains.forEach((chain, index) => {
const selection = Object.assign({}, baseSelection, {chain});
viewer.setStyle(selection, styleWith(colorForChain(chain, index)));
});
return;
}
const residueNames = useAnalyzedOnly ? analyzedNames : (VIS.structure_residue_names || analyzedNames);
if (residueNames.length === 0) {
viewer.setStyle(baseSelection, styleWith("#8a8f98"));
return;
}
residueNames.forEach(resn => {
const selection = Object.assign({}, baseSelection, {resn});
viewer.setStyle(selection, styleWith(residueColor(resn)));
});
}
function drawAxis() {
const points = VIS.axis || [];
const groups = new Map();
points.forEach(point => {
const key = String(point.axis_scope || "") + ":" + String(point.strand || 0);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(point);
});
Array.from(groups.values()).forEach((group, index) => {
const color = points.some(point => Number(point.strand || 0) > 0)
? strandColors[index % strandColors.length]
: "#00a7c7";
group.sort((a, b) => Number(a.level) - Number(b.level));
if (group.length === 1) addPoint(group[0], color, 0.34);
addCurve(group, "#07111f", 0.34, 0.28);
addCurve(group, color, 0.23, 1.0);
});
}
function drawBackbones() {
(VIS.backbones || []).forEach((backbone, index) => {
const color = strandColors[index % strandColors.length];
const points = (backbone.spline_points && backbone.spline_points.length > 1) ? backbone.spline_points : (backbone.points || []);
const usingSpline = backbone.spline_points && backbone.spline_points.length > 1;
if (points.length === 1) addPoint(points[0], color, usingSpline ? 0.16 : 0.28);
addCurve(points, "#101820", usingSpline ? 0.24 : 0.32, 0.42);
addCurve(points, color, usingSpline ? 0.15 : 0.22, 1.0);
});
}
function tableRowsForTab(tab) {
const params = VIS.parameters || {};
const rowsFor = (name, fallback = null) => {
if (Object.prototype.hasOwnProperty.call(params, name)) return params[name] || [];
return fallback ? (params[fallback] || []) : [];
};
if (tab === "global_base_pair" || tab === "local_base_pair") {
const source = tab === "global_base_pair"
? rowsFor("global_base_base", "base_pair")
: rowsFor("local_base_base", "local_base_pair");
return source.map(row => ({
id: `${tab}:${row.partner_strand || 0}:${row.level}`,
type: "base_pair",
level: Number(row.level),
partnerStrand: row.partner_strand === undefined || row.partner_strand === null ? null : Number(row.partner_strand),
features: [null, null, "shear", "stretch", "stagger", "buckle", "propel", "opening"],
cells: [
row.level,
row.duplex || "",
fmt(row.shear),
fmt(row.stretch),
fmt(row.stagger),
fmt(row.buckle),
fmt(row.propel),
fmt(row.opening),
],
row
}));
}
if (tab === "global_base_pair_axis") {
const source = rowsFor("global_base_pair_axis").length
? rowsFor("global_base_pair_axis")
: (rowsFor("curvesplus_base_pair_axis").length ? rowsFor("curvesplus_base_pair_axis") : rowsFor("base_pair_axis"));
return source.map(row => ({
id: `${tab}:${row.partner_strand || 0}:${row.level}`,
type: "base_pair_axis",
level: Number(row.level),
partnerStrand: row.partner_strand === undefined || row.partner_strand === null ? null : Number(row.partner_strand),
features: [null, null, "xdisp", "ydisp", "inclin", "tip"],
cells: [
row.level,
row.duplex || "",
fmt(row.xdisp),
fmt(row.ydisp),
fmt(row.inclin),
fmt(row.tip),
],
row
}));
}
if (tab === "global_base_axis") {
return rowsFor("global_base_axis", "base_axis").map(row => ({
id: `${tab}:${row.strand || 0}:${row.level}`,
type: "base_axis",
level: Number(row.level),
strand: row.strand === undefined || row.strand === null ? null : Number(row.strand),
features: [null, null, null, "xdisp", "ydisp", "inclin", "tip"],
cells: [
row.level,
row.strand || "",
`${row.residue_name || ""} ${row.residue_id || ""}`.trim(),
fmt(row.xdisp),
fmt(row.ydisp),
fmt(row.inclin),
fmt(row.tip),
],
row
}));
}
if (["global_strand_step", "global_base_pair_step", "local_strand_step", "local_base_pair_step"].includes(tab)) {
const sources = {
global_strand_step: ["global_inter_base", "global_strand_step"],
global_base_pair_step: ["global_inter_base_pair", "global_step"],
local_strand_step: ["local_inter_base", "local_strand_step"],
local_base_pair_step: ["local_inter_base_pair", "local_step"]
};
const [name, fallback] = sources[tab];
const source = rowsFor(name, fallback);
return source.map(row => ({
id: `${tab}:${row.strand || row.partner_strand || 0}:${row.level}`,
type: "step",
tab,
level: Number(row.level),
nextLevel: row.next_level === undefined || row.next_level === null ? Number(row.level) + 1 : Number(row.next_level),
strand: row.strand === undefined || row.strand === null ? null : Number(row.strand),
partnerStrand: row.partner_strand === undefined || row.partner_strand === null ? null : Number(row.partner_strand),
features: [null, null, "shift", "slide", "rise", "tilt", "roll", "twist"],
cells: [
row.level,
row.duplex || row.step || (row.strand ? `Strand ${row.strand}` : ""),
fmt(row.shift),
fmt(row.slide),
fmt(row.rise),
fmt(row.tilt),
fmt(row.roll),
fmt(row.twist),
],
row
}));
}
if (tab === "global_axis_curvature") {
return rowsFor(tab).map(row => ({
id: `${tab}:${row.strand || 0}:${row.level}`,
type: "data",
level: Number(row.level),
strand: row.strand === undefined || row.strand === null ? null : Number(row.strand),
features: [null, null, null, null, "ax", "ay", "ainc", "atip", "adis", "angle", "path", null],
cells: [
row.level,
row.next_level,
row.strand,
row.step || "",
fmt(row.ax),
fmt(row.ay),
fmt(row.ainc),
fmt(row.atip),
fmt(row.adis),
fmt(row.angle),
fmt(row.path),
fmt(row.dc),
],
row
}));
}
if (tab === "global_axis_bending") {
return rowsFor(tab).map(row => ({
id: `${tab}:${row.strand || 0}:${row.level}`,
type: "data",
level: Number(row.level),
strand: row.strand === undefined || row.strand === null ? null : Number(row.strand),
features: [null, null, null, "offset", "local_direction"],
cells: [
row.level,
row.strand,
`${row.residue_name || ""} ${row.residue_id || ""}`.trim(),
fmt(row.offset),
fmt(row.local_direction),
],
row
}));
}
if (tab === "global_axis_bending_summary") {
return rowsFor(tab).map(row => ({
id: `${tab}:${row.strand || 0}`,
type: "data",
level: null,
strand: row.strand === undefined || row.strand === null ? null : Number(row.strand),
features: [null, "path_length", "end_to_end", "shortening_percent", "overall_bend_uu", "overall_bend_pp"],
cells: [
row.strand,
fmt(row.path_length),
fmt(row.end_to_end),
fmt(row.shortening_percent),
fmt(row.overall_bend_uu),
fmt(row.overall_bend_pp),
],
row
}));
}
if (tab === "backbone") {
return rowsFor(tab).map(row => ({
id: `${tab}:${row.strand || 0}:${row.level}`,
type: "data",
level: Number(row.level),
strand: row.strand === undefined || row.strand === null ? null : Number(row.strand),
features: [null, null, null, null, null, "c1_c2", "c2_c3", "phase", "amplitude", "c1_prime", "c2_prime", "c3_prime", "chi", "gamma", "delta", "epsilon", "zeta", "alpha", "beta"],
cells: [
row.level,
row.strand,
`${row.residue_name || ""} ${row.residue_id || ""}`.trim(),
row.status || "",
row.pucker || "",
fmt(row.c1_c2),
fmt(row.c2_c3),
fmt(row.phase),
fmt(row.amplitude),
fmt(row.c1_prime),
fmt(row.c2_prime),
fmt(row.c3_prime),
fmt(row.chi),
fmt(row.gamma),
fmt(row.delta),
fmt(row.epsilon),
fmt(row.zeta),
fmt(row.alpha),
fmt(row.beta),
],
row
}));
}
return (params.groove || []).map(row => ({
id: `groove:${row.level}:${row.sub_level}`,
type: "groove",
level: Number(row.level),
subLevel: Number(row.sub_level),
features: [null, null, "minor_width", "minor_depth", "minor_angle", "major_width", "major_depth", "major_angle", "diameter"],
cells: [
row.level + "." + row.sub_level,
row.base_pair || "",
fmt(row.minor_width),
fmt(row.minor_depth),
fmt(row.minor_angle),
fmt(row.major_width),
fmt(row.major_depth),
fmt(row.major_angle),
fmt(row.diameter),
],
row
}));
}
function headersForTab(tab) {
if (tab === "global_base_pair" || tab === "local_base_pair") return ["Level", "Pair", "Shear", "Stretch", "Stagger", "Buckle", "Propel", "Open"];
if (tab === "global_base_pair_axis") return ["Level", "Pair", "Xdisp", "Ydisp", "Inclin", "Tip"];
if (tab === "global_base_axis") return ["Level", "Strand", "Residue", "Xdisp", "Ydisp", "Inclin", "Tip"];
if (["global_strand_step", "global_base_pair_step", "local_strand_step", "local_base_pair_step"].includes(tab)) return ["Step", "Residues", "Shift", "Slide", "Rise", "Tilt", "Roll", "Twist"];
if (tab === "global_axis_curvature") return ["Level", "Next", "Strand", "Step", "Ax", "Ay", "Ainc", "Atip", "Adis", "Angle", "Path", "DC"];
if (tab === "global_axis_bending") return ["Level", "Strand", "Residue", "Offset", "Local Direction"];
if (tab === "global_axis_bending_summary") return ["Strand", "Path Length", "End-to-End", "Shortening %", "Bend UU", "Bend PP"];
if (tab === "backbone") return ["Level", "Strand", "Residue", "Status", "Pucker", "C1-C2", "C2-C3", "Phase", "Amplitude", "C1'", "C2'", "C3'", "Chi", "Gamma", "Delta", "Epsilon", "Zeta", "Alpha", "Beta"];
return ["Level", "Pair", "Minor W", "Minor D", "Minor A", "Major W", "Major D", "Major A", "Diam"];
}
function renderParameterTable() {
const rows = tableRowsForTab(activeTab);
const container = document.getElementById("parameterTable");
if (!rows.length) {
container.innerHTML = "<div class=\"empty\">No records for this tab.</div>";
return;
}
const headers = headersForTab(activeTab);
const annotations = rows.map(entry => {
if (!["global_base_pair", "local_base_pair"].includes(activeTab)) return null;
const pair = pairAtLevel(entry.level, entry.partnerStrand);
return pairAnnotation(pair) || (!pair && baseAtLevel(1, entry.level)
? {kind: "warning", label: "Unpaired", detail: "No inferred base pair"}
: null);
});
container.innerHTML = `
<table>
<thead><tr>${headers.map(header => `<th>${header}</th>`).join("")}</tr></thead>
<tbody>
${rows.map((entry, index) => {
const annotation = annotations[index];
return `
<tr data-index="${index}" class="${selectedInspection && selectedInspection.id === entry.id ? "selected" : ""}${annotation ? " annotation-" + annotation.kind : ""}">
${entry.cells.map((cell, cellIndex) => {
const feature = (entry.features || [])[cellIndex] || "";
const selected = feature && selectedInspection && selectedInspection.id === entry.id && selectedInspection.feature === feature;
const badge = cellIndex === 0 && annotation ? `<span class="annotation-badge ${annotation.kind}">${annotation.label}</span>` : "";
return `<td data-feature="${feature}" class="${selected ? "selected-feature" : ""}">${badge}${cell}</td>`;
}).join("")}
</tr>
`;
}).join("")}
</tbody>
</table>
`;
container.querySelectorAll("tbody tr").forEach(row => {
const annotation = annotations[Number(row.dataset.index)];
if (annotation) row.title = annotation.detail;
row.addEventListener("click", event => {
const entry = rows[Number(row.dataset.index)];
const cell = event.target.closest("td");
const feature = cell ? (cell.dataset.feature || null) : null;
selectedInspection = Object.assign({}, entry, {
type: ["global_base_pair", "local_base_pair"].includes(activeTab) && !pairAtLevel(entry.level, entry.partnerStrand) ? "sequence" : entry.type,
feature,
featureLabel: feature ? featureLabel(feature) : null,
featureValue: feature ? entry.row[feature] : null
});
if (Number.isFinite(Number(entry.level))) highlightSequenceLevel(entry.level);
renderParameterTable();
redrawSelection();
});
});
}
function setActiveTab(tab) {
activeTab = tab;
selectedInspection = null;
document.querySelectorAll(".tabs button").forEach(button => {
button.classList.toggle("active", button.dataset.tab === tab);
});
renderParameterTable();
redrawSelection();
}
function drawActualBlocks() {
const onlyUnusual = document.getElementById("showOnlyUnusual").checked;
const showLabels = document.getElementById("showLabels").checked;
(VIS.base_pairs || []).forEach(pair => {
if (onlyUnusual && !isUnusual(pair)) return;
const color = pairColor(pair);
const first = actualBlockForPair(pair.first, pair);
const second = actualBlockForPair(pair.second, pair);
drawBasePlate(first, second, color, {outline: false});
drawBasePlate(second, first, color, {outline: false});
addSegment(pair.first, pair.second, color, pair.is_canonical ? 0.05 : 0.08, pair.is_canonical ? 0.35 : 0.7);
if (showLabels && pair.midpoint) {
addLabel(viewer.addLabel(pair.label || `Level ${pair.level}`, {
position: xyz(pair.midpoint),
fontSize: 10,
fontColor: "#162033",
backgroundColor: "#ffffff",
backgroundOpacity: 0.72,
borderThickness: 0.5,
borderColor: color
}));
}
});
}
function drawAnalyticalBaseFrames() {
(VIS.base_origins || []).forEach(base => {
const analytical = analyticalBasePlate(base);
drawFrameAxes(analytical, analytical.analysis_frame_source === "contact_geometry" ? 1.25 : 1.0);
});
(VIS.base_pairs || []).forEach(pair => {
if (pair.frame_mode !== "contact_geometry" && pair.is_canonical) return;
const color = pairColor(pair);
const first = analyticalBasePlate(pair.first);
const second = analyticalBasePlate(pair.second);
addDashedSegment(first.frame_origin || first, second.frame_origin || second, color, 0.035, 9, 0.72);
});
}
function drawSelectedBasePair(entry) {
const pair = pairAtLevel(entry.level, entry.partnerStrand);
if (!pair) return;
const color = "#ff2f00";
const first = actualBlockForPair(pair.first, pair);
const second = actualBlockForPair(pair.second, pair);
drawBasePlate(first, second, color, {fillOpacity: 0.32, edgeRadius: 0.12});
drawBasePlate(second, first, color, {fillOpacity: 0.32, edgeRadius: 0.12});
drawAnalyticalPairSource(pair, color);
const frame = pairFrame(pair);
drawFrameGlyph(frame, 1.65, "#111827");
if (entry.feature && drawFeatureMeasurement(frame, entry.feature, entry.featureValue, color)) return;
addValueLabel(pair.frame_midpoint || pair.midpoint, `Pair frame level ${entry.level}`, color);
}
function drawSelectedSequence(entry) {
const bases = (VIS.base_origins || []).filter(base => Number(base.level) === Number(entry.level));
bases.forEach(base => drawBasePlate(base, null, "#ff2f00", {fillOpacity: 0.5, edgeRadius: 0.09}));
if (bases.length) addValueLabel(bases[0], `Sequence level ${entry.level}`, "#ff2f00");
}
function drawSelectedBasePairAxis(entry) {
const pair = pairAtLevel(entry.level, entry.partnerStrand);
if (!pair) return;
const color = "#ff2f00";
drawSelectedBasePair(Object.assign({}, entry, {feature: null, featureValue: null}));
const frame = pairFrame(pair);
const axis = axisAtLevel(entry.level);
if (axis) {
addPoint(axis, color, 0.46);
addArrowLikeSegment(frame.origin, axis, color, 0.075, 0.9);
}
if (entry.feature && drawFeatureMeasurement(frame, entry.feature, entry.featureValue, color)) return;
if (axis) addValueLabel(axis, `Axis relation level ${entry.level}`, color);
}
function drawSelectedBaseAxis(entry) {
const base = baseAtLevel(entry.strand, entry.level);
if (!base) return;
const color = "#ff2f00";
const analytical = analyticalBasePlate(base);
const origin = analytical.frame_origin || analytical;
const frame = baseAnalysisFrame(base);
drawFrameAxes(analytical, 1.45);
const axis = axisAtLevel(entry.level, entry.strand);
if (axis) {
addPoint(axis, color, 0.44);
addArrowLikeSegment(origin, axis, color, 0.075, 0.88);
}
if (entry.feature && drawFeatureMeasurement(frame, entry.feature, entry.featureValue, color)) return;
addValueLabel(origin, `Strand ${entry.strand} level ${entry.level}`, color);
}
function stepEndpointFrame(entry, level) {
if (entry.strand !== null && entry.strand !== undefined) {
return baseAnalysisFrame(baseAtLevel(entry.strand, level));
}
const pair = pairAtLevel(level, entry.partnerStrand);
return pairFrame(pair);
}
function drawStepEndpoint(entry, level) {
if (entry.strand !== null && entry.strand !== undefined) {
const base = baseAtLevel(entry.strand, level);
if (base) drawFrameAxes(analyticalBasePlate(base), 1.25);
return;
}
const pair = pairAtLevel(level, entry.partnerStrand);
if (pair) {
drawAnalyticalPairSource(pair, "#ff2f00");
drawFrameGlyph(pairFrame(pair), 1.35, "#111827");
}
}
function drawSelectedStep(entry) {
const color = "#ff2f00";
const nextLevel = entry.nextLevel || entry.level + 1;
drawStepEndpoint(entry, entry.level);
drawStepEndpoint(entry, nextLevel);
const firstFrame = stepEndpointFrame(entry, entry.level);
const secondFrame = stepEndpointFrame(entry, nextLevel);
if (firstFrame && secondFrame) {
addArrowLikeSegment(firstFrame.origin, secondFrame.origin, color, 0.12, 0.9);
const midOrigin = pointFrom(scaleVec(addVec(vec(firstFrame.origin), vec(secondFrame.origin)), 0.5));
const midFrame = averageFrames(firstFrame, secondFrame, midOrigin);
drawFrameGlyph(midFrame, 1.45, "#111827");
if (entry.feature && drawFeatureMeasurement(midFrame, entry.feature, entry.featureValue, color)) return;
addValueLabel(midOrigin, entry.strand ? `Strand ${entry.strand} step ${entry.level}` : `Step ${entry.level}`, color);
}
const axis1 = axisAtLevel(entry.level, entry.strand);
const axis2 = axisAtLevel(nextLevel, entry.strand);
if (axis1 && axis2) {
addSegment(axis1, axis2, color, 0.24, 0.72);
}
}
function drawGrooveMeasurement(row, side, color, feature = null) {
const geometry = row.geometry && row.geometry[side];
if (!geometry) return;
const widthFeature = `${side}_width`;
const depthFeature = `${side}_depth`;
const angleFeature = `${side}_angle`;
const showWidth = !feature || feature === widthFeature || feature === "diameter";
const showDepth = !feature || feature === depthFeature;
const showAngle = feature === angleFeature;
if (showWidth && row[widthFeature] !== null && row[widthFeature] !== undefined) {
addSegment(geometry.width_endpoint_1, geometry.width_endpoint_2, color, 0.15, 1.0);
addPoint(geometry.width_endpoint_1, color, 0.24);
addPoint(geometry.width_endpoint_2, color, 0.24);
const mid = pointFrom(scaleVec(addVec(vec(geometry.width_endpoint_1), vec(geometry.width_endpoint_2)), 0.5));
addValueLabel(mid, `${featureLabel(widthFeature)} ${fmt(row[widthFeature])} A`, color);
}
if (showDepth && row[depthFeature] !== null && row[depthFeature] !== undefined) {
addSegment(geometry.depth_reference, geometry.depth_point, color, 0.09, 0.9);
addPoint(geometry.depth_point, color, 0.22);
addValueLabel(geometry.depth_point, `${featureLabel(depthFeature)} ${fmt(row[depthFeature])} A`, color);
}
if (showAngle && row[angleFeature] !== null && row[angleFeature] !== undefined) {
addSegment(geometry.width_endpoint_1, geometry.depth_point, color, 0.07, 0.78);
addSegment(geometry.width_endpoint_2, geometry.depth_point, color, 0.07, 0.78);
addValueLabel(geometry.depth_point, `${featureLabel(angleFeature)} ${fmt(row[angleFeature])} deg`, color);
}
}
function drawSelectedGroove(entry) {
const feature = entry.feature;
const showMinor = !feature || feature.startsWith("minor_") || feature === "diameter";
const showMajor = !feature || feature.startsWith("major_") || feature === "diameter";
if (showMinor) drawGrooveMeasurement(entry.row, "minor", "#d12b72", feature);
if (showMajor) drawGrooveMeasurement(entry.row, "major", "#6246ea", feature);
const axis = axisAtLevel(entry.level);
if (axis) {
addPoint(axis, "#111827", 0.42);
const label = feature ? `${featureLabel(feature)} ${fmt(entry.featureValue)}${featureUnit(feature) ? " " + featureUnit(feature) : ""}` : `Groove ${entry.level}.${entry.subLevel}`;
addValueLabel(axis, label, "#111827");
}
}
function drawInspectionSelection() {
if (!selectedInspection) return;
if (selectedInspection.type === "sequence") drawSelectedSequence(selectedInspection);
if (selectedInspection.type === "base_pair") drawSelectedBasePair(selectedInspection);
if (selectedInspection.type === "base_pair_axis") drawSelectedBasePairAxis(selectedInspection);
if (selectedInspection.type === "base_axis") drawSelectedBaseAxis(selectedInspection);
if (selectedInspection.type === "step") drawSelectedStep(selectedInspection);
if (selectedInspection.type === "groove") drawSelectedGroove(selectedInspection);
}
function redrawSelection() {
if (!viewer) return;
clearSelection();
drawingSelection = true;
try {
drawInspectionSelection();
} finally {
drawingSelection = false;
}
viewer.render();
}
function redraw() {
if (!viewer) return;
clearOverlays();
drawStructure();
if (document.getElementById("showAxis").checked) drawAxis();
if (document.getElementById("showBackbone").checked) drawBackbones();
if (document.getElementById("showActualBlocks").checked) drawActualBlocks();
if (document.getElementById("showAnalyticalBases").checked) drawAnalyticalBaseFrames();
drawingSelection = true;
try {
drawInspectionSelection();
} finally {
drawingSelection = false;
}
viewer.render();
}
function initialize() {
if (!window.$3Dmol) {
const error = document.getElementById("loadError");
error.hidden = false;
error.textContent = "3Dmol.js could not be loaded. Check the network connection or serve this page with a bundled viewer library.";
return;
}
viewer = $3Dmol.createViewer("viewer", {backgroundColor: "white"});
viewer.addModel(STRUCTURE_TEXT, STRUCTURE_FORMAT);
redraw();
viewer.zoomTo();
viewer.render();
}
document.querySelectorAll("input[type='checkbox']").forEach(input => {
input.addEventListener("change", redraw);
});
document.querySelectorAll(".tabs button").forEach(button => {
button.hidden = !tableRowsForTab(button.dataset.tab).length;
button.classList.toggle("active", button.dataset.tab === activeTab);
button.addEventListener("click", () => setActiveTab(button.dataset.tab));
});
document.querySelectorAll(".feature-group").forEach(group => {
group.hidden = !Array.from(group.querySelectorAll("button")).some(button => !button.hidden);
});
document.getElementById("colorMode").addEventListener("change", redraw);
document.getElementById("resetView").addEventListener("click", () => {
if (!viewer) return;
viewer.zoomTo();
viewer.render();
});
window.addEventListener("resize", () => {
if (viewer) viewer.resize();
});
renderSequenceNavigator();
renderParameterTable();
window.addEventListener("load", initialize);
</script>
</body>
</html>
"""
if __name__ == "__main__":
main()