dsh-molbio-panel 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -4
  2. package/lib/client.js +1075 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # dsh-molbio-panel
2
2
 
3
- DSH Web GUI 的**浏览器内 Molbio 面板**:右侧栏的一个 tab,列出当前会话工作区的序列文件,
4
- 选中当场画出来——`.dna` / `.gb` / `.gbk` 出质粒图谱,`.fa` / `.fasta` 出序列标识图。
3
+ DSH Web GUI 的**浏览器内 Molbio 面板**:右侧栏的一个 tab,列出当前会话工作区的序列与结构文件,
4
+ 选中当场画出来——`.dna` / `.gb` / `.gbk` 出质粒图谱,`.fa` / `.fasta` 出序列标识图,
5
+ `.pdb` / `.ent` / `.cif` / `.mmcif` / `.sdf` / `.mol` 出可旋转的 3D
6
+ ball-and-stick / space-filling 原子模型。
5
7
 
6
8
  解析与渲染在浏览器里跑的是 [`dsh-molbio-tools`](https://github.com/lovy2004/dsh-molbio-tools)
7
- 的**同一份源码**(`lib/genbank/snapgene/plasmid/msa/logo`)——不落盘 SVG、不弹系统查看器、
9
+ 的**同一份源码**(`lib/genbank/snapgene/plasmid/msa/logo` 与 `structure-preview` /
10
+ `structure-render`)——
11
+ 不落盘 SVG、不弹系统查看器、
8
12
  不经过工具调用。
9
13
 
10
14
  ## 为什么是单独的包
@@ -37,7 +41,10 @@ dsh plugin --profile <profile> add D:\path\to\packages\molbio-panel # 本地
37
41
  1. 打开右侧栏(会话头部角落的按钮),从引导页选 **Molbio**。
38
42
  2. 左栏列出工作区里可打开的文件(质粒文件排在前面)。
39
43
  3. 点一个 `.dna` / `.gb` → 右侧出图谱 + 特征表(Feature / Type / Start / End / Strand);
40
- 点一个 `.fasta`(≥2 条序列)→ 出序列标识图(信息量 bits,小样本校正默认开)。
44
+ 点一个 `.fasta`(≥2 条序列)→ 出序列标识图(信息量 bits,小样本校正默认开);
45
+ 点一个 `.pdb` / `.cif` / `.sdf` / `.mol` → 出可旋转的 3D 模型(ball-and-stick 或
46
+ space-filling;拖拽旋转、滚轮缩放、双击复位)。它是真实原子模型的预览,
47
+ 不含分子表面/静电势,也不做测量;V3000 不支持。
41
48
 
42
49
  ## 限制
43
50
 
package/lib/client.js CHANGED
@@ -7908,6 +7908,825 @@ window.__ModuleLoader__.load({
7908
7908
  exports.renderHydropathyPlot = renderHydropathyPlot;
7909
7909
  return exports;
7910
7910
  };
7911
+ __molbio_modules["structure-preview.mjs"] = () => {
7912
+ const exports = {};
7913
+ /**
7914
+ * dsh-molbio-tools/structure-preview.mjs
7915
+ *
7916
+ * Pure parsing for structure files shown in the browser panel: PDB, mmCIF and
7917
+ * MDL SDF/MOL V2000. Rendering lives in structure-render.mjs, which turns this
7918
+ * record into a realistic ball-and-stick or space-filling RGB frame.
7919
+ *
7920
+ * WHAT THIS IS
7921
+ * ------------
7922
+ * A compact, dependency-free reader for the coordinates, elements, chains and
7923
+ * bonds a preview needs. It does not interpret secondary structure, surfaces,
7924
+ * symmetry or multiple models; a failed/unsupported file throws a
7925
+ * MolbioInputError naming the file kind, and the panel turns that into a notice.
7926
+ *
7927
+ * The module is browser-safe on purpose: it imports only MolbioInputError from
7928
+ * lib.mjs. It must not import node:fs, node:path or node:zlib.
7929
+ */
7930
+ const { MolbioInputError } = __molbio_require("lib.mjs");
7931
+ /** Extension to parser kind. The keys are lowercase. */
7932
+ const STRUCTURE_EXTENSIONS = {
7933
+ pdb: 'pdb',
7934
+ ent: 'pdb',
7935
+ cif: 'mmcif',
7936
+ mmcif: 'mmcif',
7937
+ sdf: 'sdf',
7938
+ mol: 'sdf',
7939
+ };
7940
+
7941
+ /** Upper bound on atoms read from one file. */
7942
+ const MAX_STRUCTURE_ATOMS = 20000;
7943
+
7944
+ /** Upper bound on bonds read from one file. */
7945
+ const MAX_STRUCTURE_BONDS = 40000;
7946
+
7947
+ /** The format label for a filename, or undefined when it is not a structure. */
7948
+ function structureFormatOf(name) {
7949
+ const match = /\.([A-Za-z0-9]+)$/.exec(String(name));
7950
+ if (match === null) return undefined;
7951
+ return STRUCTURE_EXTENSIONS[match[1].toLowerCase()];
7952
+ }
7953
+
7954
+ /**
7955
+ * Parse one structure file.
7956
+ * @param {string} format 'pdb' | 'mmcif' | 'sdf'
7957
+ * @param {string} text file content (UTF-8 decoded).
7958
+ * @param {string} [name] display name, used for the title fallback.
7959
+ * @returns {object} the parsed record consumed by structure-render.mjs.
7960
+ */
7961
+ function parseStructureText(format, text, name = 'structure') {
7962
+ const kind = String(format).toLowerCase();
7963
+ if (kind === 'pdb') return withSummary(parsePdb(text, name));
7964
+ if (kind === 'mmcif') return withSummary(parseMmcif(text, name));
7965
+ if (kind === 'sdf') return withSummary(parseSdf(text, name));
7966
+ throw new MolbioInputError('the structure parser accepts pdb, mmcif or sdf/mol files');
7967
+ }
7968
+
7969
+ // ── shared atom helpers ─────────────────────────────────────────────────────
7970
+
7971
+ const ELEMENTS = new Set(['H', 'C', 'N', 'O', 'S', 'P', 'F', 'CL', 'BR', 'I', 'B', 'SI', 'SE', 'FE', 'ZN', 'MG', 'CA', 'NA', 'K']);
7972
+
7973
+ /** Element symbol from an atom name, when the file does not carry one. */
7974
+ function guessElement(atomName) {
7975
+ const cleaned = String(atomName ?? '').toUpperCase().replace(/[^A-Z]/g, '');
7976
+ if (cleaned === '') return 'C';
7977
+ const two = cleaned.slice(0, 2);
7978
+ if (ELEMENTS.has(two)) return two;
7979
+ return cleaned.slice(0, 1);
7980
+ }
7981
+
7982
+ /** Element symbol, normalised to upper case and at most two characters. */
7983
+ function normalizeElement(value, fallbackName) {
7984
+ const element = String(value ?? '').trim().toUpperCase();
7985
+ if (element !== '' && element !== '?' && element !== '.') return element.slice(0, 2);
7986
+ return guessElement(fallbackName);
7987
+ }
7988
+
7989
+ /** One atom record; all coordinates are finite numbers. */
7990
+ function atomRecord({ serial, name, element, residue, chain, residueNumber, x, y, z, hetatm }) {
7991
+ return {
7992
+ serial,
7993
+ name: String(name ?? '').trim(),
7994
+ element: normalizeElement(element, name),
7995
+ residue: String(residue ?? '').trim(),
7996
+ chain: String(chain ?? '').trim(),
7997
+ residue_number: String(residueNumber ?? '').trim(),
7998
+ x,
7999
+ y,
8000
+ z,
8001
+ hetatm: hetatm === true,
8002
+ };
8003
+ }
8004
+
8005
+ /** Reject a file that produced no usable coordinates. */
8006
+ function requireAtoms(atoms, format) {
8007
+ if (atoms.length === 0) {
8008
+ throw new MolbioInputError(`the ${format} file contains no usable atom coordinates`);
8009
+ }
8010
+ if (atoms.length > MAX_STRUCTURE_ATOMS) {
8011
+ throw new MolbioInputError(`the file has ${atoms.length} atoms; the panel reads at most ${MAX_STRUCTURE_ATOMS}`);
8012
+ }
8013
+ }
8014
+
8015
+ /** Attach the derived facts the panel shows, without copying the arrays. */
8016
+ function withSummary(record) {
8017
+ const chains = [...new Set(record.atoms.map((atom) => atom.chain).filter((chain) => chain !== ''))];
8018
+ const residues = new Set(record.atoms.map((atom) => `${atom.chain}:${atom.residue_number}:${atom.residue}`));
8019
+ return {
8020
+ ...record,
8021
+ chains,
8022
+ chain_count: chains.length,
8023
+ residue_count: residues.size,
8024
+ hetatm_count: record.atoms.filter((atom) => atom.hetatm).length,
8025
+ };
8026
+ }
8027
+
8028
+ // ── PDB ─────────────────────────────────────────────────────────────────────
8029
+
8030
+ /** Parse PDB ATOM/HETATM records from the first model. */
8031
+ function parsePdb(text, name = 'structure') {
8032
+ const atoms = [];
8033
+ const serials = new Map();
8034
+ const conect = [];
8035
+ const titleParts = [];
8036
+ let model = 0;
8037
+ let sawModel = false;
8038
+
8039
+ for (const rawLine of String(text).split(/\r?\n/)) {
8040
+ const lineText = rawLine.padEnd(80, ' ');
8041
+ const recordName = lineText.slice(0, 6).trim().toUpperCase();
8042
+ if (recordName === 'TITLE') {
8043
+ titleParts.push(lineText.slice(10).trim());
8044
+ continue;
8045
+ }
8046
+ if (recordName === 'MODEL') {
8047
+ sawModel = true;
8048
+ model += 1;
8049
+ continue;
8050
+ }
8051
+ if (recordName === 'ENDMDL') {
8052
+ if (atoms.length > 0) break;
8053
+ continue;
8054
+ }
8055
+ if (recordName !== 'ATOM' && recordName !== 'HETATM') {
8056
+ if (recordName === 'CONECT') {
8057
+ const numbers = lineText.slice(6).trim().split(/\s+/).map((value) => Number(value)).filter((value) => Number.isInteger(value));
8058
+ if (numbers.length > 1) conect.push(numbers);
8059
+ }
8060
+ continue;
8061
+ }
8062
+ if (sawModel && model > 1) continue;
8063
+ const altLocation = lineText.slice(16, 17);
8064
+ if (altLocation !== ' ' && altLocation !== 'A') continue;
8065
+ const x = Number(lineText.slice(30, 38));
8066
+ const y = Number(lineText.slice(38, 46));
8067
+ const z = Number(lineText.slice(46, 54));
8068
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
8069
+ const serial = Number(lineText.slice(6, 11).trim());
8070
+ const atom = atomRecord({
8071
+ serial: Number.isInteger(serial) ? serial : atoms.length + 1,
8072
+ name: lineText.slice(12, 16),
8073
+ element: lineText.slice(76, 78),
8074
+ residue: lineText.slice(17, 20),
8075
+ chain: lineText.slice(21, 22),
8076
+ residueNumber: lineText.slice(22, 26),
8077
+ x,
8078
+ y,
8079
+ z,
8080
+ hetatm: recordName === 'HETATM',
8081
+ });
8082
+ if (Number.isInteger(atom.serial)) serials.set(atom.serial, atoms.length);
8083
+ atoms.push(atom);
8084
+ }
8085
+
8086
+ requireAtoms(atoms, 'PDB');
8087
+ const bonds = [];
8088
+ for (const numbers of conect) {
8089
+ const from = serials.get(numbers[0]);
8090
+ if (from === undefined) continue;
8091
+ for (const target of numbers.slice(1)) {
8092
+ const to = serials.get(target);
8093
+ if (to !== undefined) bonds.push({ a: from, b: to, order: 1 });
8094
+ }
8095
+ }
8096
+ return {
8097
+ kind: 'structure',
8098
+ format: 'pdb',
8099
+ name,
8100
+ title: titleParts.join(' ').replace(/\s+/g, ' ').trim(),
8101
+ atoms,
8102
+ bonds: bonds.slice(0, MAX_STRUCTURE_BONDS),
8103
+ };
8104
+ }
8105
+
8106
+ // ── mmCIF ───────────────────────────────────────────────────────────────────
8107
+
8108
+ /** Split a CIF document into tokens, honouring quotes and ; text blocks. */
8109
+ function cifTokens(text) {
8110
+ const tokens = [];
8111
+ const lines = String(text).split(/\r?\n/);
8112
+ let index = 0;
8113
+ while (index < lines.length) {
8114
+ const lineText = lines[index];
8115
+ if (lineText.startsWith(';')) {
8116
+ const parts = [];
8117
+ index += 1;
8118
+ while (index < lines.length && !lines[index].startsWith(';')) {
8119
+ parts.push(lines[index]);
8120
+ index += 1;
8121
+ }
8122
+ index += 1;
8123
+ tokens.push(parts.join('\n'));
8124
+ continue;
8125
+ }
8126
+ let position = 0;
8127
+ while (position < lineText.length) {
8128
+ while (position < lineText.length && /\s/.test(lineText[position])) position += 1;
8129
+ if (position >= lineText.length) break;
8130
+ if (lineText[position] === '#') break;
8131
+ const quote = lineText[position];
8132
+ if (quote === "'" || quote === '"') {
8133
+ position += 1;
8134
+ const start = position;
8135
+ while (position < lineText.length && lineText[position] !== quote) position += 1;
8136
+ tokens.push(lineText.slice(start, position));
8137
+ if (position < lineText.length) position += 1;
8138
+ continue;
8139
+ }
8140
+ const start = position;
8141
+ while (position < lineText.length && !/\s/.test(lineText[position])) position += 1;
8142
+ tokens.push(lineText.slice(start, position));
8143
+ }
8144
+ index += 1;
8145
+ }
8146
+ return tokens;
8147
+ }
8148
+
8149
+ /** Index of the first tag matching one of the candidate names. */
8150
+ function firstTagIndex(tags, candidates) {
8151
+ for (const candidate of candidates) {
8152
+ const index = tags.indexOf(candidate);
8153
+ if (index !== -1) return index;
8154
+ }
8155
+ return -1;
8156
+ }
8157
+
8158
+ /** Parse the _atom_site loop of an mmCIF document. */
8159
+ function parseMmcif(text, name = 'structure') {
8160
+ const tokens = cifTokens(text);
8161
+ let atoms = [];
8162
+ for (let index = 0; index < tokens.length; index++) {
8163
+ if (tokens[index].toLowerCase() !== 'loop_') continue;
8164
+ const tags = [];
8165
+ let cursor = index + 1;
8166
+ while (cursor < tokens.length && tokens[cursor].startsWith('_')) {
8167
+ tags.push(tokens[cursor]);
8168
+ cursor += 1;
8169
+ }
8170
+ if (!tags.some((tag) => tag.startsWith('_atom_site.'))) {
8171
+ index = cursor - 1;
8172
+ continue;
8173
+ }
8174
+ const values = [];
8175
+ while (cursor < tokens.length) {
8176
+ const token = tokens[cursor];
8177
+ if (token.startsWith('_') || token.toLowerCase() === 'loop_' || token.toLowerCase() === 'stop_' || token.toLowerCase().startsWith('data_')) break;
8178
+ values.push(token);
8179
+ cursor += 1;
8180
+ }
8181
+ const rowWidth = tags.length;
8182
+ if (rowWidth === 0) continue;
8183
+ const column = (candidates) => firstTagIndex(tags, candidates);
8184
+ const groupColumn = column(['_atom_site.group_PDB']);
8185
+ const atomColumn = column(['_atom_site.label_atom_id', '_atom_site.auth_atom_id']);
8186
+ const residueColumn = column(['_atom_site.label_comp_id', '_atom_site.auth_comp_id']);
8187
+ const chainColumn = column(['_atom_site.auth_asym_id', '_atom_site.label_asym_id']);
8188
+ const residueNumberColumn = column(['_atom_site.auth_seq_id', '_atom_site.label_seq_id']);
8189
+ const xColumn = column(['_atom_site.Cartn_x']);
8190
+ const yColumn = column(['_atom_site.Cartn_y']);
8191
+ const zColumn = column(['_atom_site.Cartn_z']);
8192
+ const elementColumn = column(['_atom_site.type_symbol']);
8193
+ const modelColumn = column(['_atom_site.pdbx_PDB_model_num']);
8194
+ const altColumn = column(['_atom_site.label_alt_id']);
8195
+ if (xColumn === -1 || yColumn === -1 || zColumn === -1) continue;
8196
+ const rows = [];
8197
+ for (let start = 0; start + rowWidth <= values.length; start += rowWidth) {
8198
+ rows.push(values.slice(start, start + rowWidth));
8199
+ }
8200
+ let firstModel;
8201
+ const parsed = [];
8202
+ for (const row of rows) {
8203
+ const cell = (position) => (position === -1 ? '' : row[position]);
8204
+ const modelValue = cell(modelColumn);
8205
+ if (firstModel === undefined && modelValue !== '' && modelValue !== '.' && modelValue !== '?') firstModel = modelValue;
8206
+ if (firstModel !== undefined && modelValue !== '' && modelValue !== '.' && modelValue !== '?' && modelValue !== firstModel) continue;
8207
+ const alt = cell(altColumn);
8208
+ if (alt !== '' && alt !== '.' && alt !== '?' && alt !== 'A') continue;
8209
+ const x = Number(cell(xColumn));
8210
+ const y = Number(cell(yColumn));
8211
+ const z = Number(cell(zColumn));
8212
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
8213
+ const group = cell(groupColumn).toUpperCase();
8214
+ parsed.push(atomRecord({
8215
+ serial: parsed.length + 1,
8216
+ name: cell(atomColumn),
8217
+ element: cell(elementColumn),
8218
+ residue: cell(residueColumn),
8219
+ chain: cell(chainColumn),
8220
+ residueNumber: cell(residueNumberColumn),
8221
+ x,
8222
+ y,
8223
+ z,
8224
+ hetatm: group === 'HETATM',
8225
+ }));
8226
+ }
8227
+ atoms = parsed;
8228
+ break;
8229
+ }
8230
+
8231
+ requireAtoms(atoms, 'mmCIF');
8232
+ return {
8233
+ kind: 'structure',
8234
+ format: 'mmcif',
8235
+ name,
8236
+ title: '',
8237
+ atoms,
8238
+ bonds: [],
8239
+ };
8240
+ }
8241
+
8242
+ // ── SDF / MOL V2000 ─────────────────────────────────────────────────────────
8243
+
8244
+ /** Parse the first molecule of an SDF/MOL V2000 file. */
8245
+ function parseSdf(text, name = 'structure') {
8246
+ // The first record ends at $$$$; later records are deliberately ignored.
8247
+ const first = String(text).split(/^\$\$\$\$/m)[0];
8248
+ const lines = first.split(/\r?\n/);
8249
+ const title = (lines[0] ?? '').trim();
8250
+ let countsIndex = -1;
8251
+ for (let index = 0; index < Math.min(lines.length, 8); index++) {
8252
+ if (/V2000|V3000/.test(lines[index])) {
8253
+ countsIndex = index;
8254
+ break;
8255
+ }
8256
+ }
8257
+ if (countsIndex === -1) throw new MolbioInputError('the SDF/MOL file has no V2000 counts line');
8258
+ if (/V3000/.test(lines[countsIndex])) {
8259
+ throw new MolbioInputError('MDL V3000 is not supported; export the molecule as V2000 SDF or MOL');
8260
+ }
8261
+ const counts = lines[countsIndex] ?? '';
8262
+ const atomCount = Number.parseInt(counts.slice(0, 3), 10);
8263
+ const bondCount = Number.parseInt(counts.slice(3, 6), 10);
8264
+ if (!Number.isInteger(atomCount) || atomCount <= 0) throw new MolbioInputError('the SDF/MOL counts line declares no atoms');
8265
+ if (atomCount > MAX_STRUCTURE_ATOMS) throw new MolbioInputError(`the file has ${atomCount} atoms; the panel reads at most ${MAX_STRUCTURE_ATOMS}`);
8266
+ const atoms = [];
8267
+ for (let offset = 0; offset < atomCount; offset++) {
8268
+ const lineText = lines[countsIndex + 1 + offset] ?? '';
8269
+ const x = Number(lineText.slice(0, 10));
8270
+ const y = Number(lineText.slice(10, 20));
8271
+ const z = Number(lineText.slice(20, 30));
8272
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
8273
+ const symbol = lineText.slice(31, 34).trim() || lineText.trim().split(/\s+/)[3] || 'C';
8274
+ atoms.push(atomRecord({
8275
+ serial: offset + 1,
8276
+ name: symbol,
8277
+ element: symbol,
8278
+ residue: '',
8279
+ chain: '',
8280
+ residueNumber: '',
8281
+ x,
8282
+ y,
8283
+ z,
8284
+ hetatm: false,
8285
+ }));
8286
+ }
8287
+ requireAtoms(atoms, 'SDF/MOL');
8288
+ const bonds = [];
8289
+ const bondStart = countsIndex + 1 + atomCount;
8290
+ for (let offset = 0; offset < bondCount; offset++) {
8291
+ const lineText = lines[bondStart + offset] ?? '';
8292
+ const from = Number.parseInt(lineText.slice(0, 3), 10) - 1;
8293
+ const to = Number.parseInt(lineText.slice(3, 6), 10) - 1;
8294
+ const order = Number.parseInt(lineText.slice(6, 9), 10);
8295
+ if (from < 0 || to < 0 || from >= atoms.length || to >= atoms.length) continue;
8296
+ bonds.push({ a: from, b: to, order: Number.isInteger(order) && order > 0 ? order : 1 });
8297
+ }
8298
+ return {
8299
+ kind: 'structure',
8300
+ format: 'sdf',
8301
+ name,
8302
+ title,
8303
+ atoms,
8304
+ bonds: bonds.slice(0, MAX_STRUCTURE_BONDS),
8305
+ };
8306
+ }
8307
+
8308
+ exports.STRUCTURE_EXTENSIONS = STRUCTURE_EXTENSIONS;
8309
+ exports.MAX_STRUCTURE_ATOMS = MAX_STRUCTURE_ATOMS;
8310
+ exports.MAX_STRUCTURE_BONDS = MAX_STRUCTURE_BONDS;
8311
+ exports.structureFormatOf = structureFormatOf;
8312
+ exports.parseStructureText = parseStructureText;
8313
+ exports.parsePdb = parsePdb;
8314
+ exports.parseMmcif = parseMmcif;
8315
+ exports.parseSdf = parseSdf;
8316
+ return exports;
8317
+ };
8318
+ __molbio_modules["structure-render.mjs"] = () => {
8319
+ const exports = {};
8320
+ /**
8321
+ * dsh-molbio-tools/structure-render.mjs
8322
+ *
8323
+ * A small, dependency-free software renderer for the realistic 3D structure
8324
+ * preview: ball-and-stick and space-filling models with perspective, a z-buffer
8325
+ * and simple Phong lighting.
8326
+ *
8327
+ * WHY A SOFTWARE RENDERER
8328
+ * -----------------------
8329
+ * The DSH Web client is a browser, but this package ships zero runtime
8330
+ * dependencies and must keep the browser half Node-free. Canvas 2D is available
8331
+ * everywhere, yet gradients/shadow support differs between engines and is hard
8332
+ * to test in Node. Rendering an RGB buffer here gives:
8333
+ *
8334
+ * - one implementation shared by the panel and the tests;
8335
+ * - a PNG the tests can encode and inspect without a browser;
8336
+ * - deterministic pixels for a given record and view;
8337
+ * - no WebGL context, shader compilation or asset download.
8338
+ *
8339
+ * The tradeoff is CPU rasterization. The panel renders at reduced supersampling
8340
+ * while a pointer drag is active and at full quality when it settles; the
8341
+ * atom cap keeps a single frame bounded.
8342
+ *
8343
+ * WHAT THIS IS NOT
8344
+ * ----------------
8345
+ * No molecular surface, no electrostatics, no ambient occlusion, no secondary
8346
+ * structure ribbons. It is a realistic atom/bond model, not an analysis tool.
8347
+ */
8348
+ const { MolbioInputError } = __molbio_require("lib.mjs");
8349
+ /** The representations the panel offers, in display order. */
8350
+ const STRUCTURE_REPRESENTATIONS = ['ball-and-stick', 'space-filling'];
8351
+
8352
+ /** CPK-like colours, exported so the panel legend and renderer cannot drift. */
8353
+ const STRUCTURE_ELEMENT_COLORS = Object.freeze({
8354
+ H: '#e8ebef',
8355
+ C: '#6b7480',
8356
+ N: '#2f6fdb',
8357
+ O: '#d64545',
8358
+ S: '#d0a02a',
8359
+ P: '#e07a2f',
8360
+ F: '#3aa76d',
8361
+ CL: '#3aa76d',
8362
+ BR: '#a6512c',
8363
+ I: '#7b4bb7',
8364
+ FE: '#c26a2e',
8365
+ ZN: '#7b8794',
8366
+ MG: '#3aa76d',
8367
+ CA: '#3aa76d',
8368
+ NA: '#3aa76d',
8369
+ K: '#3aa76d',
8370
+ });
8371
+
8372
+ /** The initial camera the panel uses when a structure is selected. */
8373
+ const DEFAULT_STRUCTURE_VIEW = Object.freeze({
8374
+ yaw: 0.55,
8375
+ pitch: 0.18,
8376
+ zoom: 1,
8377
+ representation: 'ball-and-stick',
8378
+ });
8379
+
8380
+ /** A single frame above this atom count is refused rather than freezing the tab. */
8381
+ const MAX_RENDER_ATOMS = 12000;
8382
+
8383
+ const VDW = { H: 1.20, C: 1.70, N: 1.55, O: 1.52, P: 1.80, S: 1.80, F: 1.47, CL: 1.75, BR: 1.85, I: 1.98, FE: 1.80, ZN: 1.39, MG: 1.73, CA: 1.95, NA: 2.27, K: 2.75 };
8384
+ const COVALENT = { H: 0.31, C: 0.76, N: 0.71, O: 0.66, P: 1.07, S: 1.05, F: 0.57, CL: 1.02, BR: 1.20, I: 1.39, FE: 1.24, ZN: 1.22, MG: 1.41, CA: 1.76, NA: 1.66, K: 2.03 };
8385
+ const BG = [247, 249, 251];
8386
+ const EMPTY = new Uint8Array(0);
8387
+
8388
+ /** Is this atom a crystallographic water that should not clutter the preview? */
8389
+ function isWater(atom) {
8390
+ const residue = String(atom.residue ?? '').toUpperCase();
8391
+ return residue === 'HOH' || residue === 'WAT' || residue === 'DOD';
8392
+ }
8393
+
8394
+ /** Rounded element colour, with a neutral fallback for metals and unknowns. */
8395
+ function elementColor(element) {
8396
+ return STRUCTURE_ELEMENT_COLORS[String(element ?? '').toUpperCase()] ?? '#5b9b74';
8397
+ }
8398
+
8399
+ /** Hex colour to [r,g,b]. */
8400
+ function rgbOf(hex) {
8401
+ return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
8402
+ }
8403
+
8404
+ /** Spatial hash of atom indices on a cubic grid. */
8405
+ function gridIndex(atoms, cell) {
8406
+ const grid = new Map();
8407
+ for (let index = 0; index < atoms.length; index++) {
8408
+ const atom = atoms[index];
8409
+ const key = `${Math.floor(atom.x / cell)},${Math.floor(atom.y / cell)},${Math.floor(atom.z / cell)}`;
8410
+ const bucket = grid.get(key);
8411
+ if (bucket === undefined) grid.set(key, [index]);
8412
+ else bucket.push(index);
8413
+ }
8414
+ return grid;
8415
+ }
8416
+
8417
+ /**
8418
+ * Infer a covalent bond graph from 3D coordinates.
8419
+ *
8420
+ * Covalent radii (not van der Waals radii) are the right scale for a bond: a
8421
+ * vdW-sum threshold would connect every atom pair in a tightly packed helix.
8422
+ * The 0.45 Å tolerance absorbs coordinate precision without reaching the next
8423
+ * bonded neighbour in the common C-C/N-C/O-C cases.
8424
+ * @param {Array} atoms
8425
+ * @returns {Array<[number, number]>} index pairs into `atoms`.
8426
+ */
8427
+ function inferStructureBonds(atoms) {
8428
+ const bonds = [];
8429
+ if (!Array.isArray(atoms) || atoms.length < 2 || atoms.length > MAX_RENDER_ATOMS) return bonds;
8430
+ const cell = 2.4;
8431
+ const grid = gridIndex(atoms, cell);
8432
+ for (let i = 0; i < atoms.length; i++) {
8433
+ const a = atoms[i];
8434
+ if (a.element === 'H') continue;
8435
+ const gx = Math.floor(a.x / cell);
8436
+ const gy = Math.floor(a.y / cell);
8437
+ const gz = Math.floor(a.z / cell);
8438
+ for (let dx = -1; dx <= 1; dx++) {
8439
+ for (let dy = -1; dy <= 1; dy++) {
8440
+ for (let dz = -1; dz <= 1; dz++) {
8441
+ const bucket = grid.get(`${gx + dx},${gy + dy},${gz + dz}`);
8442
+ if (bucket === undefined) continue;
8443
+ for (const j of bucket) {
8444
+ if (j <= i) continue;
8445
+ const b = atoms[j];
8446
+ if (b.element === 'H') continue;
8447
+ const distance = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
8448
+ const limit = (COVALENT[a.element] ?? 0.8) + (COVALENT[b.element] ?? 0.8) + 0.45;
8449
+ if (distance > 0.35 && distance <= limit) bonds.push([i, j]);
8450
+ }
8451
+ }
8452
+ }
8453
+ }
8454
+ }
8455
+ return bonds;
8456
+ }
8457
+
8458
+ /** Filter atoms the preview does not draw, keeping a serial -> index map. */
8459
+ function preparedAtoms(record) {
8460
+ const atoms = [];
8461
+ const bySerial = new Map();
8462
+ for (const atom of record.atoms) {
8463
+ if (atom.element === 'H' || isWater(atom)) continue;
8464
+ if (!Number.isFinite(atom.x) || !Number.isFinite(atom.y) || !Number.isFinite(atom.z)) continue;
8465
+ bySerial.set(atom.serial, atoms.length);
8466
+ atoms.push(atom);
8467
+ }
8468
+ return { atoms, bySerial };
8469
+ }
8470
+
8471
+ /** Explicit bonds from the parser, mapped onto the filtered atom array. */
8472
+ function explicitBonds(record, bySerial) {
8473
+ const bonds = [];
8474
+ const seen = new Set();
8475
+ for (const bond of record.bonds ?? []) {
8476
+ const fromAtom = record.atoms[bond.a];
8477
+ const toAtom = record.atoms[bond.b];
8478
+ if (fromAtom === undefined || toAtom === undefined) continue;
8479
+ const a = bySerial.get(fromAtom.serial);
8480
+ const b = bySerial.get(toAtom.serial);
8481
+ if (a === undefined || b === undefined || a === b) continue;
8482
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
8483
+ if (seen.has(key)) continue;
8484
+ seen.add(key);
8485
+ bonds.push([a, b]);
8486
+ }
8487
+ return bonds;
8488
+ }
8489
+
8490
+ /** The bond list used by the renderer for one representation. */
8491
+ function bondList(record, atoms, bySerial, representation) {
8492
+ if (representation === 'space-filling') return [];
8493
+ if (record.format === 'sdf') return explicitBonds(record, bySerial);
8494
+ const inferred = inferStructureBonds(atoms);
8495
+ const explicit = explicitBonds(record, bySerial);
8496
+ if (explicit.length === 0) return inferred;
8497
+ const seen = new Set(inferred.map(([a, b]) => (a < b ? `${a}:${b}` : `${b}:${a}`)));
8498
+ const merged = [...inferred];
8499
+ for (const [a, b] of explicit) {
8500
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
8501
+ if (seen.has(key)) continue;
8502
+ seen.add(key);
8503
+ merged.push([a, b]);
8504
+ }
8505
+ return merged;
8506
+ }
8507
+
8508
+ /** Clamp a finite number, or use the fallback. */
8509
+ function numberOr(value, fallback, min, max) {
8510
+ const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
8511
+ return Math.max(min, Math.min(max, numeric));
8512
+ }
8513
+
8514
+ /**
8515
+ * Render one structure frame to an RGB buffer.
8516
+ *
8517
+ * @param {object} record a parseStructureText record.
8518
+ * @param {object} [options]
8519
+ * @param {number} [options.width=640]
8520
+ * @param {number} [options.height=480]
8521
+ * @param {1|2} [options.supersample=2] 1 is the interaction-quality setting.
8522
+ * @param {number} [options.yaw=0.55]
8523
+ * @param {number} [options.pitch=0.18]
8524
+ * @param {number} [options.zoom=1]
8525
+ * @param {'ball-and-stick'|'space-filling'} [options.representation='ball-and-stick']
8526
+ * @returns {{width:number,height:number,rgb:Uint8Array,atoms:number,bonds:number,representation:string}}
8527
+ */
8528
+ function renderStructureImage(record, options = {}) {
8529
+ if (record === null || typeof record !== 'object' || !Array.isArray(record.atoms)) {
8530
+ throw new MolbioInputError('renderStructureImage needs a parsed structure record');
8531
+ }
8532
+ const representation = options.representation ?? 'ball-and-stick';
8533
+ if (!STRUCTURE_REPRESENTATIONS.includes(representation)) {
8534
+ throw new MolbioInputError(`unknown structure representation ${JSON.stringify(representation)}; use ${STRUCTURE_REPRESENTATIONS.join(' or ')}`);
8535
+ }
8536
+ const width = Math.round(numberOr(options.width, 640, 64, 1600));
8537
+ const height = Math.round(numberOr(options.height, 480, 64, 1200));
8538
+ const supersample = Math.round(numberOr(options.supersample, 2, 1, 2));
8539
+ const yaw = numberOr(options.yaw, DEFAULT_STRUCTURE_VIEW.yaw, -Math.PI * 8, Math.PI * 8);
8540
+ const pitch = numberOr(options.pitch, DEFAULT_STRUCTURE_VIEW.pitch, -1.45, 1.45);
8541
+ const zoom = numberOr(options.zoom, 1, 0.35, 5);
8542
+ const { atoms, bySerial } = preparedAtoms(record);
8543
+ if (atoms.length === 0) throw new MolbioInputError('the structure has no heavy atoms to draw');
8544
+ if (atoms.length > MAX_RENDER_ATOMS) {
8545
+ throw new MolbioInputError(`the structure has ${atoms.length} heavy atoms; the preview renders at most ${MAX_RENDER_ATOMS}`);
8546
+ }
8547
+ const bonds = bondList(record, atoms, bySerial, representation);
8548
+ const W = width * supersample;
8549
+ const H = height * supersample;
8550
+
8551
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity;
8552
+ for (const atom of atoms) {
8553
+ if (atom.x < minX) minX = atom.x;
8554
+ if (atom.x > maxX) maxX = atom.x;
8555
+ if (atom.y < minY) minY = atom.y;
8556
+ if (atom.y > maxY) maxY = atom.y;
8557
+ if (atom.z < minZ) minZ = atom.z;
8558
+ if (atom.z > maxZ) maxZ = atom.z;
8559
+ }
8560
+ const cx = (minX + maxX) / 2;
8561
+ const cy = (minY + maxY) / 2;
8562
+ const cz = (minZ + maxZ) / 2;
8563
+ let radius = 6;
8564
+ for (const atom of atoms) radius = Math.max(radius, Math.hypot(atom.x - cx, atom.y - cy, atom.z - cz));
8565
+ const camera = (radius * 2.0) / zoom;
8566
+ const focal = Math.min(W, H) * 1.15;
8567
+ const cyaw = Math.cos(yaw), syaw = Math.sin(yaw);
8568
+ const cp = Math.cos(pitch), sp = Math.sin(pitch);
8569
+
8570
+ const project = (atom) => {
8571
+ const x = atom.x - cx;
8572
+ const y = atom.y - cy;
8573
+ const z = atom.z - cz;
8574
+ const x1 = x * cyaw + z * syaw;
8575
+ const z1 = -x * syaw + z * cyaw;
8576
+ const y1 = y * cp - z1 * sp;
8577
+ const z2 = y * sp + z1 * cp;
8578
+ const depth = camera - z2;
8579
+ const scale = focal / Math.max(1, depth);
8580
+ return { x: W / 2 + x1 * scale, y: H / 2 - y1 * scale, depth, scale };
8581
+ };
8582
+ const projected = atoms.map(project);
8583
+
8584
+ const rgb = new Uint8Array(W * H * 3);
8585
+ for (let y = 0; y < H; y++) {
8586
+ const t = y / Math.max(1, H - 1);
8587
+ const r = Math.round(248 - 5 * t);
8588
+ const g = Math.round(250 - 5 * t);
8589
+ const b = Math.round(252 - 4 * t);
8590
+ for (let x = 0; x < W; x++) {
8591
+ const offset = (y * W + x) * 3;
8592
+ rgb[offset] = r;
8593
+ rgb[offset + 1] = g;
8594
+ rgb[offset + 2] = b;
8595
+ }
8596
+ }
8597
+ const depthBuffer = new Float32Array(W * H).fill(Infinity);
8598
+ const light = [0.35, 0.45, 0.82];
8599
+ const lightLength = Math.hypot(light[0], light[1], light[2]);
8600
+ const L = [light[0] / lightLength, light[1] / lightLength, light[2] / lightLength];
8601
+
8602
+ const shade = (base, nx, ny, nz, depth) => {
8603
+ const diffuse = Math.max(0, nx * L[0] + ny * L[1] + nz * L[2]);
8604
+ const hx = L[0], hy = L[1], hz = L[2] + 1;
8605
+ const hLength = Math.hypot(hx, hy, hz);
8606
+ const specular = Math.pow(Math.max(0, (nx * hx + ny * hy + nz * hz) / hLength), 42) * 0.35;
8607
+ const ambient = 0.30;
8608
+ const fog = Math.max(0, Math.min(0.28, (depth - camera * 0.45) / (camera * 1.8)));
8609
+ const lightLevel = ambient + diffuse * 0.68;
8610
+ return [
8611
+ Math.round((base[0] * lightLevel + 255 * specular) * (1 - fog) + BG[0] * fog),
8612
+ Math.round((base[1] * lightLevel + 255 * specular) * (1 - fog) + BG[1] * fog),
8613
+ Math.round((base[2] * lightLevel + 255 * specular) * (1 - fog) + BG[2] * fog),
8614
+ ];
8615
+ };
8616
+
8617
+ // Bonds are rasterized as shaded capsules. The z-buffer makes draw order
8618
+ // irrelevant for overlaps, so no global primitive sort is needed.
8619
+ if (representation !== 'space-filling') {
8620
+ for (const [i, j] of bonds) {
8621
+ const a = projected[i];
8622
+ const b = projected[j];
8623
+ const bondRadius = 0.18 * ((a.scale + b.scale) / 2);
8624
+ const dx = b.x - a.x;
8625
+ const dy = b.y - a.y;
8626
+ const length2 = dx * dx + dy * dy;
8627
+ if (length2 < 1e-6 || bondRadius < 0.25) continue;
8628
+ const padding = bondRadius + 2;
8629
+ const minPX = Math.max(0, Math.floor(Math.min(a.x, b.x) - padding));
8630
+ const maxPX = Math.min(W - 1, Math.ceil(Math.max(a.x, b.x) + padding));
8631
+ const minPY = Math.max(0, Math.floor(Math.min(a.y, b.y) - padding));
8632
+ const maxPY = Math.min(H - 1, Math.ceil(Math.max(a.y, b.y) + padding));
8633
+ const base = [116, 126, 141];
8634
+ for (let py = minPY; py <= maxPY; py++) {
8635
+ for (let px = minPX; px <= maxPX; px++) {
8636
+ const t = Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / length2));
8637
+ const qx = a.x + dx * t;
8638
+ const qy = a.y + dy * t;
8639
+ const vx = px - qx;
8640
+ const vy = py - qy;
8641
+ const distance = Math.hypot(vx, vy);
8642
+ if (distance > bondRadius) continue;
8643
+ const front = Math.sqrt(Math.max(0, bondRadius * bondRadius - distance * distance)) / Math.max(1e-6, (a.scale + b.scale) / 2);
8644
+ const depth = a.depth + (b.depth - a.depth) * t - front;
8645
+ const index = py * W + px;
8646
+ if (depth >= depthBuffer[index]) continue;
8647
+ const nx = vx / Math.max(1e-6, bondRadius);
8648
+ const ny = -vy / Math.max(1e-6, bondRadius);
8649
+ const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
8650
+ const colour = shade(base, nx, ny, nz, depth);
8651
+ const offset = index * 3;
8652
+ rgb[offset] = colour[0];
8653
+ rgb[offset + 1] = colour[1];
8654
+ rgb[offset + 2] = colour[2];
8655
+ depthBuffer[index] = depth;
8656
+ }
8657
+ }
8658
+ }
8659
+ }
8660
+
8661
+ // Front-to-back atom order reduces z-buffer writes, but correctness comes
8662
+ // from the depth test, not the order.
8663
+ const order = atoms.map((_, index) => index).sort((left, right) => projected[left].depth - projected[right].depth);
8664
+ for (const index of order) {
8665
+ const atom = atoms[index];
8666
+ const point = projected[index];
8667
+ const baseRadius = (VDW[atom.element] ?? 1.5) * (representation === 'space-filling' ? 1.0 : 0.28);
8668
+ const screenRadius = baseRadius * point.scale;
8669
+ if (screenRadius < 0.4) continue;
8670
+ const base = rgbOf(elementColor(atom.element));
8671
+ const minPX = Math.max(0, Math.floor(point.x - screenRadius - 1));
8672
+ const maxPX = Math.min(W - 1, Math.ceil(point.x + screenRadius + 1));
8673
+ const minPY = Math.max(0, Math.floor(point.y - screenRadius - 1));
8674
+ const maxPY = Math.min(H - 1, Math.ceil(point.y + screenRadius + 1));
8675
+ const radius2 = screenRadius * screenRadius;
8676
+ for (let py = minPY; py <= maxPY; py++) {
8677
+ const dy = py - point.y;
8678
+ for (let px = minPX; px <= maxPX; px++) {
8679
+ const dx = px - point.x;
8680
+ const distance2 = dx * dx + dy * dy;
8681
+ if (distance2 > radius2) continue;
8682
+ const dz = Math.sqrt(Math.max(0, radius2 - distance2));
8683
+ const depth = point.depth - dz / point.scale;
8684
+ const bufferIndex = py * W + px;
8685
+ if (depth >= depthBuffer[bufferIndex]) continue;
8686
+ const nx = dx / screenRadius;
8687
+ const ny = -dy / screenRadius;
8688
+ const nz = dz / screenRadius;
8689
+ const colour = shade(base, nx, ny, nz, depth);
8690
+ const offset = bufferIndex * 3;
8691
+ rgb[offset] = colour[0];
8692
+ rgb[offset + 1] = colour[1];
8693
+ rgb[offset + 2] = colour[2];
8694
+ depthBuffer[bufferIndex] = depth;
8695
+ }
8696
+ }
8697
+ }
8698
+
8699
+ // Box-filter the supersampled image back to the requested size.
8700
+ const out = new Uint8Array(width * height * 3);
8701
+ const samples = supersample * supersample;
8702
+ for (let y = 0; y < height; y++) {
8703
+ for (let x = 0; x < width; x++) {
8704
+ let r = 0, g = 0, b = 0;
8705
+ for (let sy = 0; sy < supersample; sy++) {
8706
+ for (let sx = 0; sx < supersample; sx++) {
8707
+ const offset = ((y * supersample + sy) * W + (x * supersample + sx)) * 3;
8708
+ r += rgb[offset];
8709
+ g += rgb[offset + 1];
8710
+ b += rgb[offset + 2];
8711
+ }
8712
+ }
8713
+ const offset = (y * width + x) * 3;
8714
+ out[offset] = Math.round(r / samples);
8715
+ out[offset + 1] = Math.round(g / samples);
8716
+ out[offset + 2] = Math.round(b / samples);
8717
+ }
8718
+ }
8719
+ return { width, height, rgb: out, atoms: atoms.length, bonds: bonds.length, representation };
8720
+ }
8721
+
8722
+ exports.STRUCTURE_REPRESENTATIONS = STRUCTURE_REPRESENTATIONS;
8723
+ exports.STRUCTURE_ELEMENT_COLORS = STRUCTURE_ELEMENT_COLORS;
8724
+ exports.DEFAULT_STRUCTURE_VIEW = DEFAULT_STRUCTURE_VIEW;
8725
+ exports.MAX_RENDER_ATOMS = MAX_RENDER_ATOMS;
8726
+ exports.inferStructureBonds = inferStructureBonds;
8727
+ exports.renderStructureImage = renderStructureImage;
8728
+ return exports;
8729
+ };
7911
8730
  __molbio_modules["methylation.mjs"] = () => {
7912
8731
  const exports = {};
7913
8732
  /**
@@ -8157,12 +8976,13 @@ window.__ModuleLoader__.load({
8157
8976
  * re-exported from the SAME source files the Node plugin ships, so the browser
8158
8977
  * bundle can never drift from the plugin's behaviour.
8159
8978
  *
8160
- * Only these modules are browser-safe (they import nothing but `./lib.mjs`,
8161
- * which is pure computation): lib, genbank, snapgene, plasmid, msa, logo,
8162
- * crispr, align, seqio, plot, design. The Node-only ones (view, papers,
8163
- * records, sanger, protein is fine but unused here, index) are deliberately
8164
- * absent — importing them would drag `node:child_process` and friends into the
8165
- * browser bundle, which the bundler rejects loudly.
8979
+ * Only these modules are browser-safe (they import nothing but `./lib.mjs` and
8980
+ * `./svgio.mjs`, which are pure computation): lib, genbank, snapgene, plasmid,
8981
+ * msa, logo, crispr, align, seqio, plot, design, protein-structure,
8982
+ * structure-preview, structure-render. The Node-only ones (view, papers, records,
8983
+ * sanger, index)
8984
+ * are deliberately absent — importing them would drag `node:child_process` and
8985
+ * friends into the browser bundle, which the bundler rejects loudly.
8166
8986
  */
8167
8987
 
8168
8988
 
@@ -8182,55 +9002,66 @@ window.__ModuleLoader__.load({
8182
9002
 
8183
9003
 
8184
9004
 
8185
- exports.DNA_BASES = __molbio_require("lib.mjs").undefined;
8186
- exports.ENZYME_NAMES = __molbio_require("lib.mjs").undefined;
8187
- exports.MolbioInputError = __molbio_require("lib.mjs").undefined;
8188
- exports.baseCounts = __molbio_require("lib.mjs").undefined;
8189
- exports.complement = __molbio_require("lib.mjs").undefined;
8190
- exports.enzymeCatalog = __molbio_require("lib.mjs").undefined;
8191
- exports.enzymeCuts = __molbio_require("lib.mjs").undefined;
8192
- exports.enzymePattern = __molbio_require("lib.mjs").undefined;
8193
- exports.findRuns = __molbio_require("lib.mjs").undefined;
8194
- exports.hairpinThermo = __molbio_require("lib.mjs").undefined;
8195
- exports.isIisEnzyme = __molbio_require("lib.mjs").undefined;
8196
- exports.normalizeSequence = __molbio_require("lib.mjs").undefined;
8197
- exports.primerTm = __molbio_require("lib.mjs").undefined;
8198
- exports.reverseComplement = __molbio_require("lib.mjs").undefined;
8199
- exports.selfAnyScore = __molbio_require("lib.mjs").undefined;
8200
- exports.selfEndScore = __molbio_require("lib.mjs").undefined;
8201
- exports.parseGenBank = __molbio_require("genbank.mjs").undefined;
8202
- exports.parseSnapGeneBytes = __molbio_require("snapgene.mjs").undefined;
8203
- exports.parseXml = __molbio_require("snapgene.mjs").undefined;
8204
- exports.renderPlasmidMap = __molbio_require("plasmid.mjs").undefined;
8205
- exports.normalizeAlignedRow = __molbio_require("msa.mjs").undefined;
8206
- exports.progressiveAlign = __molbio_require("msa.mjs").undefined;
8207
- exports.conservationAnalysis = __molbio_require("msa.mjs").undefined;
8208
- exports.pairwiseIdentities = __molbio_require("msa.mjs").undefined;
8209
- exports.columnComposition = __molbio_require("logo.mjs").undefined;
8210
- exports.renderSequenceLogo = __molbio_require("logo.mjs").undefined;
8211
- exports.designGrnas = __molbio_require("crispr.mjs").undefined;
8212
- exports.findProtospacers = __molbio_require("crispr.mjs").undefined;
8213
- exports.evaluateGuide = __molbio_require("crispr.mjs").undefined;
8214
- exports.entryStats = __molbio_require("seqio.mjs").undefined;
8215
- exports.parseFasta = __molbio_require("seqio.mjs").undefined;
8216
- exports.digest = __molbio_require("lib.mjs").undefined;
8217
- exports.BUFFERS = __molbio_require("lib.mjs").undefined;
8218
- exports.METHYLATION_SENSITIVITY = __molbio_require("lib.mjs").undefined;
8219
- exports.METHYLATION_SITES = __molbio_require("lib.mjs").undefined;
8220
- exports.methylationImpact = __molbio_require("lib.mjs").undefined;
8221
- exports.methylationSites = __molbio_require("lib.mjs").undefined;
8222
- exports.sharedBuffers = __molbio_require("lib.mjs").undefined;
8223
- exports.designTaqmanProbes = __molbio_require("taqman.mjs").undefined;
8224
- exports.evaluateProbe = __molbio_require("taqman.mjs").undefined;
8225
- exports.probeCandidates = __molbio_require("taqman.mjs").undefined;
8226
- exports.checkMultiplex = __molbio_require("multiplex.mjs").undefined;
8227
- exports.helicalWheel = __molbio_require("protein-structure.mjs").undefined;
8228
- exports.hydropathyProfile = __molbio_require("protein-structure.mjs").undefined;
8229
- exports.renderHelicalWheel = __molbio_require("protein-structure.mjs").undefined;
8230
- exports.renderHydropathyPlot = __molbio_require("protein-structure.mjs").undefined;
8231
- exports.analyzeMethylation = __molbio_require("methylation.mjs").undefined;
8232
- exports.bufferReport = __molbio_require("methylation.mjs").undefined;
8233
- exports.planDoubleDigest = __molbio_require("methylation.mjs").undefined;
9005
+
9006
+
9007
+ exports.DNA_BASES = __molbio_require("lib.mjs").DNA_BASES;
9008
+ exports.ENZYME_NAMES = __molbio_require("lib.mjs").ENZYME_NAMES;
9009
+ exports.MolbioInputError = __molbio_require("lib.mjs").MolbioInputError;
9010
+ exports.baseCounts = __molbio_require("lib.mjs").baseCounts;
9011
+ exports.complement = __molbio_require("lib.mjs").complement;
9012
+ exports.enzymeCatalog = __molbio_require("lib.mjs").enzymeCatalog;
9013
+ exports.enzymeCuts = __molbio_require("lib.mjs").enzymeCuts;
9014
+ exports.enzymePattern = __molbio_require("lib.mjs").enzymePattern;
9015
+ exports.findRuns = __molbio_require("lib.mjs").findRuns;
9016
+ exports.hairpinThermo = __molbio_require("lib.mjs").hairpinThermo;
9017
+ exports.isIisEnzyme = __molbio_require("lib.mjs").isIisEnzyme;
9018
+ exports.normalizeSequence = __molbio_require("lib.mjs").normalizeSequence;
9019
+ exports.primerTm = __molbio_require("lib.mjs").primerTm;
9020
+ exports.reverseComplement = __molbio_require("lib.mjs").reverseComplement;
9021
+ exports.selfAnyScore = __molbio_require("lib.mjs").selfAnyScore;
9022
+ exports.selfEndScore = __molbio_require("lib.mjs").selfEndScore;
9023
+ exports.parseGenBank = __molbio_require("genbank.mjs").parseGenBank;
9024
+ exports.parseSnapGeneBytes = __molbio_require("snapgene.mjs").parseSnapGeneBytes;
9025
+ exports.parseXml = __molbio_require("snapgene.mjs").parseXml;
9026
+ exports.renderPlasmidMap = __molbio_require("plasmid.mjs").renderPlasmidMap;
9027
+ exports.normalizeAlignedRow = __molbio_require("msa.mjs").normalizeAlignedRow;
9028
+ exports.progressiveAlign = __molbio_require("msa.mjs").progressiveAlign;
9029
+ exports.conservationAnalysis = __molbio_require("msa.mjs").conservationAnalysis;
9030
+ exports.pairwiseIdentities = __molbio_require("msa.mjs").pairwiseIdentities;
9031
+ exports.columnComposition = __molbio_require("logo.mjs").columnComposition;
9032
+ exports.renderSequenceLogo = __molbio_require("logo.mjs").renderSequenceLogo;
9033
+ exports.designGrnas = __molbio_require("crispr.mjs").designGrnas;
9034
+ exports.findProtospacers = __molbio_require("crispr.mjs").findProtospacers;
9035
+ exports.evaluateGuide = __molbio_require("crispr.mjs").evaluateGuide;
9036
+ exports.entryStats = __molbio_require("seqio.mjs").entryStats;
9037
+ exports.parseFasta = __molbio_require("seqio.mjs").parseFasta;
9038
+ exports.digest = __molbio_require("lib.mjs").digest;
9039
+ exports.BUFFERS = __molbio_require("lib.mjs").BUFFERS;
9040
+ exports.METHYLATION_SENSITIVITY = __molbio_require("lib.mjs").METHYLATION_SENSITIVITY;
9041
+ exports.METHYLATION_SITES = __molbio_require("lib.mjs").METHYLATION_SITES;
9042
+ exports.methylationImpact = __molbio_require("lib.mjs").methylationImpact;
9043
+ exports.methylationSites = __molbio_require("lib.mjs").methylationSites;
9044
+ exports.sharedBuffers = __molbio_require("lib.mjs").sharedBuffers;
9045
+ exports.designTaqmanProbes = __molbio_require("taqman.mjs").designTaqmanProbes;
9046
+ exports.evaluateProbe = __molbio_require("taqman.mjs").evaluateProbe;
9047
+ exports.probeCandidates = __molbio_require("taqman.mjs").probeCandidates;
9048
+ exports.checkMultiplex = __molbio_require("multiplex.mjs").checkMultiplex;
9049
+ exports.helicalWheel = __molbio_require("protein-structure.mjs").helicalWheel;
9050
+ exports.hydropathyProfile = __molbio_require("protein-structure.mjs").hydropathyProfile;
9051
+ exports.renderHelicalWheel = __molbio_require("protein-structure.mjs").renderHelicalWheel;
9052
+ exports.renderHydropathyPlot = __molbio_require("protein-structure.mjs").renderHydropathyPlot;
9053
+ exports.STRUCTURE_EXTENSIONS = __molbio_require("structure-preview.mjs").STRUCTURE_EXTENSIONS;
9054
+ exports.parseStructureText = __molbio_require("structure-preview.mjs").parseStructureText;
9055
+ exports.structureFormatOf = __molbio_require("structure-preview.mjs").structureFormatOf;
9056
+ exports.DEFAULT_STRUCTURE_VIEW = __molbio_require("structure-render.mjs").DEFAULT_STRUCTURE_VIEW;
9057
+ exports.MAX_RENDER_ATOMS = __molbio_require("structure-render.mjs").MAX_RENDER_ATOMS;
9058
+ exports.STRUCTURE_ELEMENT_COLORS = __molbio_require("structure-render.mjs").STRUCTURE_ELEMENT_COLORS;
9059
+ exports.STRUCTURE_REPRESENTATIONS = __molbio_require("structure-render.mjs").STRUCTURE_REPRESENTATIONS;
9060
+ exports.inferStructureBonds = __molbio_require("structure-render.mjs").inferStructureBonds;
9061
+ exports.renderStructureImage = __molbio_require("structure-render.mjs").renderStructureImage;
9062
+ exports.analyzeMethylation = __molbio_require("methylation.mjs").analyzeMethylation;
9063
+ exports.bufferReport = __molbio_require("methylation.mjs").bufferReport;
9064
+ exports.planDoubleDigest = __molbio_require("methylation.mjs").planDoubleDigest;
8234
9065
  return exports;
8235
9066
  };
8236
9067
  __molbio_modules["build/panel-core.mjs"] = () => {
@@ -8247,7 +9078,7 @@ window.__ModuleLoader__.load({
8247
9078
  * All parsing and rendering is the SAME code the Node tools run: this module
8248
9079
  * re-exports through browser-api.mjs, which points at the root .mjs sources.
8249
9080
  */
8250
- const { MolbioInputError, parseGenBank, parseSnapGeneBytes, renderPlasmidMap, columnComposition, renderSequenceLogo, progressiveAlign } = __molbio_require("build/browser-api.mjs");
9081
+ const { MolbioInputError, parseGenBank, parseSnapGeneBytes, renderPlasmidMap, columnComposition, renderSequenceLogo, progressiveAlign, parseStructureText, renderStructureImage, DEFAULT_STRUCTURE_VIEW, STRUCTURE_ELEMENT_COLORS, STRUCTURE_EXTENSIONS, STRUCTURE_REPRESENTATIONS } = __molbio_require("build/browser-api.mjs");
8251
9082
  /** Extensions the panel can open, mapped to the parser that handles them. */
8252
9083
  const PLASMID_EXTENSIONS = {
8253
9084
  dna: 'snapgene',
@@ -8264,6 +9095,8 @@ window.__ModuleLoader__.load({
8264
9095
  fas: 'fasta',
8265
9096
  };
8266
9097
 
9098
+ /** Structure formats the panel opens, mapped to the parser kind. */
9099
+
8267
9100
  /** Lowercase extension without the dot, or '' when there is none. */
8268
9101
  function extensionOf(name) {
8269
9102
  const match = /\.([A-Za-z0-9]+)$/.exec(String(name));
@@ -8273,7 +9106,7 @@ window.__ModuleLoader__.load({
8273
9106
  /**
8274
9107
  * How the panel should treat one workspace entry.
8275
9108
  * @param {{name?: string, type?: string}} entry a `workspaceFiles.list` row.
8276
- * @returns {'plasmid'|'alignment'|'other'|'directory'}
9109
+ * @returns {'plasmid'|'alignment'|'structure'|'other'|'directory'}
8277
9110
  */
8278
9111
  function classifyEntry(entry) {
8279
9112
  if (entry === null || typeof entry !== 'object') return 'other';
@@ -8282,18 +9115,19 @@ window.__ModuleLoader__.load({
8282
9115
  const extension = extensionOf(entry.name ?? '');
8283
9116
  if (PLASMID_EXTENSIONS[extension] !== undefined) return 'plasmid';
8284
9117
  if (ALIGNMENT_EXTENSIONS[extension] !== undefined) return 'alignment';
9118
+ if (STRUCTURE_EXTENSIONS[extension] !== undefined) return 'structure';
8285
9119
  return 'other';
8286
9120
  }
8287
9121
 
8288
9122
  /** Whether the panel can open this entry at all. */
8289
9123
  function isOpenable(entry) {
8290
9124
  const kind = classifyEntry(entry);
8291
- return kind === 'plasmid' || kind === 'alignment';
9125
+ return kind === 'plasmid' || kind === 'alignment' || kind === 'structure';
8292
9126
  }
8293
9127
 
8294
- /** Sort entries: plasmid files first, then alignments, then the rest, by name. */
9128
+ /** Sort entries: plasmid files first, then alignments, structures, the rest. */
8295
9129
  function sortEntries(entries) {
8296
- const rank = { plasmid: 0, alignment: 1, other: 2, directory: 3 };
9130
+ const rank = { plasmid: 0, alignment: 1, structure: 2, other: 3, directory: 4 };
8297
9131
  return [...entries].sort((left, right) => {
8298
9132
  const byKind = rank[classifyEntry(left)] - rank[classifyEntry(right)];
8299
9133
  if (byKind !== 0) return byKind;
@@ -8392,6 +9226,44 @@ window.__ModuleLoader__.load({
8392
9226
  });
8393
9227
  }
8394
9228
 
9229
+ /**
9230
+ * Parse a structure file into the record the panel draws.
9231
+ *
9232
+ * The size guard is the same browser-specific cap as {@link parsePlasmidFile}:
9233
+ * parsing runs on the UI thread, so a 200 MB PDB must be refused with a message
9234
+ * rather than freeze the tab. The format comes from the extension, exactly as
9235
+ * the Node tools choose their parser.
9236
+ * @param {string} name file name.
9237
+ * @param {Uint8Array} bytes raw file content.
9238
+ * @returns {object} a parseStructureText record.
9239
+ */
9240
+ function parseStructureFile(name, bytes) {
9241
+ if (!(bytes instanceof Uint8Array)) throw new MolbioInputError('file content must be a Uint8Array');
9242
+ const extension = extensionOf(name);
9243
+ const format = STRUCTURE_EXTENSIONS[extension];
9244
+ if (format === undefined) {
9245
+ throw new MolbioInputError(`the panel does not open .${extension} structure files; use .pdb, .ent, .cif, .mmcif, .sdf or .mol`);
9246
+ }
9247
+ if (bytes.length > MAX_PANEL_FILE_BYTES) {
9248
+ throw new MolbioInputError(
9249
+ `${name} is ${(bytes.length / 1024 / 1024).toFixed(1)} MB; the panel opens structure files up to ${MAX_PANEL_FILE_BYTES / 1024 / 1024} MB`,
9250
+ );
9251
+ }
9252
+ return parseStructureText(format, new TextDecoder('utf-8').decode(bytes), name);
9253
+ }
9254
+
9255
+ /**
9256
+ * Render a parsed structure to an RGB buffer for the canvas.
9257
+ * @param {object} record a {@link parseStructureFile} result.
9258
+ * @param {{width?: number, height?: number, yaw?: number, pitch?: number,
9259
+ * zoom?: number, representation?: string, supersample?: 1|2}} [options]
9260
+ * @returns {{width:number, height:number, rgb:Uint8Array, atoms:number,
9261
+ * bonds:number, representation:string}}
9262
+ */
9263
+ function structureImage(record, options = {}) {
9264
+ return renderStructureImage(record, options);
9265
+ }
9266
+
8395
9267
  /**
8396
9268
  * Parse an alignment file (FASTA) into the rows the logo needs.
8397
9269
  * @param {Uint8Array} bytes raw FASTA content.
@@ -8753,6 +9625,8 @@ window.__ModuleLoader__.load({
8753
9625
  exports.MAX_PANEL_FILE_BYTES = MAX_PANEL_FILE_BYTES;
8754
9626
  exports.parsePlasmidFile = parsePlasmidFile;
8755
9627
  exports.plasmidSvg = plasmidSvg;
9628
+ exports.parseStructureFile = parseStructureFile;
9629
+ exports.structureImage = structureImage;
8756
9630
  exports.parseAlignmentFile = parseAlignmentFile;
8757
9631
  exports.logoSvg = logoSvg;
8758
9632
  exports.MAP_CARD_KIND = MAP_CARD_KIND;
@@ -8773,6 +9647,10 @@ window.__ModuleLoader__.load({
8773
9647
  exports.isMolbioDetailSubject = isMolbioDetailSubject;
8774
9648
  exports.remoteFailure = remoteFailure;
8775
9649
  exports.credentialInfo = credentialInfo;
9650
+ exports.DEFAULT_STRUCTURE_VIEW = DEFAULT_STRUCTURE_VIEW;
9651
+ exports.STRUCTURE_ELEMENT_COLORS = STRUCTURE_ELEMENT_COLORS;
9652
+ exports.STRUCTURE_EXTENSIONS = STRUCTURE_EXTENSIONS;
9653
+ exports.STRUCTURE_REPRESENTATIONS = STRUCTURE_REPRESENTATIONS;
8776
9654
  return exports;
8777
9655
  };
8778
9656
  __molbio_modules["build/client-entry.mjs"] = () => {
@@ -8809,7 +9687,7 @@ window.__ModuleLoader__.load({
8809
9687
  * inside the factory would also have to be torn down by hand on unload.
8810
9688
  */
8811
9689
  const { createElement: h, useEffect, useMemo, useRef, useState } = __ext0;
8812
- const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, figureCardSummary, figureCardView, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
9690
+ const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, figureCardSummary, figureCardView, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, parseStructureFile, plasmidSvg, readWorkspaceText, sortEntries, structureImage, DEFAULT_STRUCTURE_VIEW, STRUCTURE_ELEMENT_COLORS, STRUCTURE_REPRESENTATIONS } = __molbio_require("build/panel-core.mjs");
8813
9691
  /** This implementation's identity, and the key its body/title register under. */
8814
9692
  const PANEL_ID = 'dsh-molbio-tools';
8815
9693
  /** Type discriminator the tab is opened by. */
@@ -8887,6 +9765,20 @@ window.__ModuleLoader__.load({
8887
9765
  error: { color: 'var(--dsh-danger, #c0392b)', padding: '10px 12px', whiteSpace: 'pre-wrap' },
8888
9766
  muted: { opacity: 0.6, padding: '10px 12px' },
8889
9767
  svg: { maxWidth: '100%', height: 'auto' },
9768
+ canvas: { maxWidth: '100%', height: 'auto', display: 'block', border: '1px solid var(--dsh-border, #e3e6ea)', borderRadius: '6px', cursor: 'grab', touchAction: 'none', background: '#f7f9fb' },
9769
+ button: (active) => ({
9770
+ fontSize: '11px',
9771
+ padding: '2px 8px',
9772
+ cursor: 'pointer',
9773
+ border: '1px solid var(--dsh-border, #e3e6ea)',
9774
+ borderRadius: '4px',
9775
+ background: active ? 'var(--dsh-selected, #eaf2ff)' : 'transparent',
9776
+ fontWeight: active ? 600 : 400,
9777
+ color: 'inherit',
9778
+ }),
9779
+ legend: { display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '8px', fontSize: '11px', opacity: 0.75 },
9780
+ legendItem: { display: 'inline-flex', alignItems: 'center', gap: '4px' },
9781
+ legendDot: { width: '9px', height: '9px', borderRadius: '50%', display: 'inline-block' },
8890
9782
  table: { borderCollapse: 'collapse', marginTop: '12px', fontSize: '12px', width: '100%' },
8891
9783
  th: { textAlign: 'left', padding: '4px 8px', borderBottom: '1px solid var(--dsh-border, #e3e6ea)', opacity: 0.7, fontWeight: 600 },
8892
9784
  td: { padding: '3px 8px', borderBottom: '1px solid var(--dsh-border-soft, #f0f2f5)' },
@@ -8899,7 +9791,11 @@ window.__ModuleLoader__.load({
8899
9791
  const [selected, setSelected] = useState(null);
8900
9792
  const [record, setRecord] = useState(null);
8901
9793
  const [status, setStatus] = useState({ kind: cwd === undefined ? 'waiting' : 'loading' });
9794
+ const [view, setView] = useState({ ...DEFAULT_STRUCTURE_VIEW });
9795
+ const [dragging, setDragging] = useState(false);
8902
9796
  const svgHost = useRef(null);
9797
+ const canvasHost = useRef(null);
9798
+ const drag = useRef(null);
8903
9799
 
8904
9800
  // Directory listing, re-read when the session's workspace root changes.
8905
9801
  useEffect(() => {
@@ -8929,8 +9825,13 @@ window.__ModuleLoader__.load({
8929
9825
  const kind = classifyEntry(selected);
8930
9826
  const parsed = kind === 'plasmid'
8931
9827
  ? { kind, ...parsePlasmidFile(selected.name, bytes) }
8932
- : { kind, ...parseAlignmentFile(bytes) };
9828
+ : kind === 'alignment'
9829
+ ? { kind, ...parseAlignmentFile(bytes) }
9830
+ : { kind, ...parseStructureFile(selected.name, bytes) };
8933
9831
  setRecord(parsed);
9832
+ // A new structure starts from the same deterministic camera; the
9833
+ // previous file's yaw/pitch would be meaningless on a different molecule.
9834
+ if (parsed.kind === 'structure') setView({ ...DEFAULT_STRUCTURE_VIEW });
8934
9835
  setStatus({ kind: 'ready' });
8935
9836
  })
8936
9837
  .catch((error) => {
@@ -8950,7 +9851,7 @@ window.__ModuleLoader__.load({
8950
9851
  const host = svgHost.current;
8951
9852
  if (host === null) return;
8952
9853
  host.replaceChildren();
8953
- if (record === null || status.kind !== 'ready') return;
9854
+ if (record === null || status.kind !== 'ready' || record.kind === 'structure') return;
8954
9855
  // The renderer refuses what it cannot draw (renderPlasmidMap caps a map at
8955
9856
  // 200 features). Letting that throw out of the effect leaves the pane empty
8956
9857
  // with no explanation, so it becomes the pane's error state instead.
@@ -8971,15 +9872,90 @@ window.__ModuleLoader__.load({
8971
9872
  host.append(document.importNode(svg, true));
8972
9873
  }, [record, status.kind]);
8973
9874
 
9875
+ // Structures are rasterized into an RGB buffer and painted into a canvas.
9876
+ // The renderer is deterministic and shared with the Node tests; only the
9877
+ // canvas plumbing lives here. During a drag the frame drops to 1x
9878
+ // supersampling and returns to 2x when the pointer is released.
9879
+ useEffect(() => {
9880
+ if (record === null || record.kind !== 'structure' || status.kind !== 'ready') return;
9881
+ const canvas = canvasHost.current;
9882
+ if (canvas === null || typeof canvas.getContext !== 'function') return;
9883
+ const context = canvas.getContext('2d');
9884
+ if (context === null || typeof context.createImageData !== 'function') return;
9885
+ const width = Number.isInteger(canvas.width) ? canvas.width : 640;
9886
+ const height = Number.isInteger(canvas.height) ? canvas.height : 480;
9887
+ let image;
9888
+ try {
9889
+ image = structureImage(record, {
9890
+ width,
9891
+ height,
9892
+ yaw: view.yaw,
9893
+ pitch: view.pitch,
9894
+ zoom: view.zoom,
9895
+ representation: view.representation,
9896
+ // Large assemblies stay at interaction quality even when settled;
9897
+ // 2× sampling is reserved for the small structures it visibly helps.
9898
+ supersample: dragging || record.atoms.length > 4000 ? 1 : 2,
9899
+ });
9900
+ } catch (error) {
9901
+ setStatus({ kind: 'error', message: String(error?.message ?? error) });
9902
+ return;
9903
+ }
9904
+ const imageData = context.createImageData(width, height);
9905
+ for (let source = 0, target = 0; source < image.rgb.length; source += 3, target += 4) {
9906
+ imageData.data[target] = image.rgb[source];
9907
+ imageData.data[target + 1] = image.rgb[source + 1];
9908
+ imageData.data[target + 2] = image.rgb[source + 2];
9909
+ imageData.data[target + 3] = 255;
9910
+ }
9911
+ context.putImageData(imageData, 0, 0);
9912
+ }, [record, status.kind, view, dragging]);
9913
+
9914
+ const startDrag = (event) => {
9915
+ if (typeof event?.currentTarget?.setPointerCapture === 'function' && event.pointerId !== undefined) {
9916
+ try {
9917
+ event.currentTarget.setPointerCapture(event.pointerId);
9918
+ } catch {
9919
+ // A synthetic pointer without capture support is still draggable.
9920
+ }
9921
+ }
9922
+ drag.current = { x: event.clientX, y: event.clientY, yaw: view.yaw, pitch: view.pitch };
9923
+ setDragging(true);
9924
+ };
9925
+
9926
+ const moveDrag = (event) => {
9927
+ const start = drag.current;
9928
+ if (start === null) return;
9929
+ const yaw = start.yaw + (event.clientX - start.x) * 0.01;
9930
+ const pitch = Math.max(-1.4, Math.min(1.4, start.pitch - (event.clientY - start.y) * 0.01));
9931
+ setView((current) => ({ ...current, yaw, pitch }));
9932
+ };
9933
+
9934
+ const endDrag = () => {
9935
+ drag.current = null;
9936
+ setDragging(false);
9937
+ };
9938
+
9939
+ const zoomView = (event) => {
9940
+ // preventDefault keeps the sidebar from scrolling while the wheel zooms.
9941
+ if (typeof event.preventDefault === 'function') event.preventDefault();
9942
+ setView((current) => ({
9943
+ ...current,
9944
+ zoom: Math.max(0.35, Math.min(5, current.zoom * Math.exp(-Number(event.deltaY ?? 0) * 0.001))),
9945
+ }));
9946
+ };
9947
+
9948
+ const resetView = () => setView({ ...DEFAULT_STRUCTURE_VIEW });
9949
+
8974
9950
  const openable = useMemo(() => entries.filter(isOpenable), [entries]);
8975
9951
 
8976
- const head = h('div', { style: styles.listHead }, 'Sequence files');
9952
+ const head = h('div', { style: styles.listHead }, 'Sequence & structure files');
8977
9953
  const list = h(
8978
9954
  'div',
8979
9955
  { style: styles.list },
8980
9956
  head,
8981
9957
  ...(openable.length === 0
8982
- ? [h('div', { key: 'none', style: styles.muted }, 'No .dna / .gb / .gbk / .fasta files in this workspace')]
9958
+ ? [h('div', { key: 'none', style: styles.muted }, 'No .dna / .gb / .gbk / .fasta / .pdb / .cif / .sdf files in this workspace')]
8983
9959
  : openable.map((entry) => {
8984
9960
  const kind = classifyEntry(entry);
8985
9961
  return h(
@@ -9034,6 +10010,40 @@ window.__ModuleLoader__.load({
9034
10010
  h('span', null, `${record.rows[0]?.length ?? 0} aligned columns`)));
9035
10011
  body.push(h('div', { key: 'svg', ref: svgHost, style: styles.svg }));
9036
10012
  }
10013
+ if (status.kind === 'ready' && record !== null && record.kind === 'structure') {
10014
+ body.push(h('div', { key: 'meta', style: styles.meta },
10015
+ h('span', null, record.title === '' ? record.name : record.title),
10016
+ h('span', null, record.format.toUpperCase()),
10017
+ h('span', null, `${record.atoms.length} atoms`),
10018
+ h('span', null, `${record.chain_count} chain(s)`),
10019
+ h('span', null, `${record.residue_count} residue(s)`)));
10020
+ body.push(h('div', { key: 'controls', style: styles.meta },
10021
+ ...STRUCTURE_REPRESENTATIONS.map((representation) => h('button', {
10022
+ key: representation,
10023
+ style: styles.button(view.representation === representation),
10024
+ onClick: () => setView((current) => ({ ...current, representation })),
10025
+ }, representation === 'ball-and-stick' ? 'Ball & stick' : 'Space filling')),
10026
+ h('button', { key: 'reset', style: styles.button(false), onClick: resetView }, 'Reset view')));
10027
+ body.push(h('canvas', {
10028
+ key: 'canvas',
10029
+ ref: canvasHost,
10030
+ width: 640,
10031
+ height: 480,
10032
+ style: styles.canvas,
10033
+ onPointerDown: startDrag,
10034
+ onPointerMove: moveDrag,
10035
+ onPointerUp: endDrag,
10036
+ onPointerCancel: endDrag,
10037
+ onWheel: zoomView,
10038
+ onDoubleClick: resetView,
10039
+ 'aria-label': '3D structure preview; drag to rotate, wheel to zoom',
10040
+ }));
10041
+ body.push(h('div', { key: 'legend', style: styles.legend },
10042
+ ...['C', 'N', 'O', 'S', 'P'].map((element) => h('span', { key: element, style: styles.legendItem },
10043
+ h('span', { style: { ...styles.legendDot, background: STRUCTURE_ELEMENT_COLORS[element] } }),
10044
+ element))));
10045
+ body.push(h('div', { key: 'note', style: styles.note }, 'Software-rendered 3D model. Drag to rotate, wheel to zoom. Ball-and-stick / space-filling; not for measurements.'));
10046
+ }
9037
10047
 
9038
10048
  return h('div', { style: styles.root }, list, h('div', { style: styles.main }, ...body));
9039
10049
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-molbio-panel",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Browser panel for the DeepSeek Harness Web GUI: a Molbio tab in the right sidebar that draws plasmid maps and sequence logos from workspace sequence files, using the same computation modules as the dsh-molbio-tools plugin.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",