dsh-molbio-tools 0.24.0 → 0.25.0

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,115 @@
10
10
  改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
11
11
  下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
12
12
 
13
+ ## [0.25.0] — 2026-11-10(浏览器内 3D 结构预览:真实原子模型)
14
+
15
+ **工具数不变(64),benchmark 不变(65 题 / 64 覆盖)。** 这一版把结构文件接进已有的
16
+ **Molbio** 右栏面板:选中 `.pdb` / `.ent` / `.cif` / `.mmcif` / `.sdf` / `.mol`,
17
+ 浏览器当场渲染一个可旋转、可缩放的**真实 3D 原子模型**(ball-and-stick / space-filling),
18
+ 不需要工具调用、不落盘 SVG、不弹系统查看器,也没有引入任何第三方库。
19
+
20
+ > 0.25.0 在发布前从 2D PCA 投影改成了 3D。2D 预览只有一张固定投影,丢失深度、遮挡和
21
+ > 原子空间关系;学术界常见的结构配图是立体原子/表面模型,而不是单张 PCA 线图。
22
+
23
+ ### 1. 解析:`structure-preview.mjs`
24
+
25
+ - **PDB**:只读第一个 MODEL,跳过非 A/A 的 altLoc;读 `ATOM`/`HETATM` 的坐标、元素、
26
+ 残基、链与 `CONECT`。
27
+ - **mmCIF**:读 `_atom_site` 循环,quote 与 `;` 文本块都能切词;优先 `auth_*` 列,
28
+ 缺列时回退 `label_*`;只取第一个 model。
29
+ - **SDF/MOL**:只读第一条记录(`$$$$` 之前),解析 V2000 的原子与键;**V3000 明确拒绝**
30
+ 并提示导出 V2000,而不是猜。
31
+ - 解析器只负责坐标/元素/链/键;渲染在独立模块里,浏览器与 Node 测试共用同一份源码。
32
+
33
+ ### 2. 软件渲染:`structure-render.mjs`
34
+
35
+ - **不依赖 WebGL / Three.js**:纯 JS 软件光栅化,返回 RGB 缓冲,Canvas 只负责
36
+ `putImageData`。这样同一套像素可以被 Node 测试和文档截图复用。
37
+ - 透视投影 + **z-buffer** + 球面/柱面 Phong 光照 + 深度雾化;CPK 配色
38
+ (C 灰、N 蓝、O 红、S 黄、P 橙)。
39
+ - 两种表示:**ball-and-stick**(小球 + 键)与 **space-filling**(范德华球)。
40
+ - 键图:SDF 用显式键;PDB/mmCIF 用共价半径空间网格推断,并与 `CONECT` 合并
41
+ (1CRN 的 CONECT 只有 6 条,不能直接当完整键表)。
42
+ - 边界:面板最多渲染 **12,000 个重原子**;水与氢默认不画;拖动时 1× 采样、松开后 2×;
43
+ 单帧约 50–120 ms(1CRN 327 原子 / 1BNA 486 原子),不做分子表面、静电势或测量。
44
+ - 明确写"preview, not for measurements":这是看的模型,不是分析工具。
45
+
46
+ ### 3. 面板
47
+
48
+ - Molbio tab 的文件分类新增 `structure`;列表、排序、元信息(标题/格式/原子数/链数/残基数)
49
+ 复用现有状态机。
50
+ - 结构记录改用 `<canvas>`:**拖拽旋转、滚轮缩放、双击复位**;工具栏切换
51
+ ball-and-stick / space-filling;元素图例与操作提示在画布下方。
52
+ - 质粒图谱与 sequence logo 仍用原来的 SVG 宿主;只有结构记录走 canvas。
53
+ - 只改浏览器半也改变了 `lib/client.js`,所以根包 `0.24.1 → 0.25.0`、面板包
54
+ `0.1.10 → 0.1.11` 同时升版本。
55
+
56
+ ### 4. 顺带修掉的 bundler 缺陷
57
+
58
+ 客户端 bundler 在生成 re-export 时丢掉了 imported name,产物里所有
59
+ `browser-api.mjs` 再导出都变成 `require(target).undefined`。格式检查一直是绿的,但真实
60
+ 浏览器半会拿到 `undefined`。现在 re-export 保留 `imported`,并在 `test/client.mjs` 里
61
+ 加了"产物不得含 `require(...).undefined`"的结构断言。
62
+
63
+ ### 5. 测试与边界
64
+
65
+ - `test/structure.mjs`:PDB/mmCIF/SDF 已知坐标与计数;共价键推断;渲染像素
66
+ (中心原子、非背景像素、确定性、两种表示、PNG 编码);V3000/空坐标/原子 cap 的错误路径。
67
+ - `test/panel-render.mjs`:真实 PDB 点选后 canvas 被绘制,拖拽会触发重绘。
68
+ - `test/client.mjs`、`test/client-mount.mjs`、`test/contract.mjs`(17 项)与
69
+ `build/client-bundle.mjs --check` 全绿;工具数仍 64,benchmark 仍 65 题。
70
+ - 没有采用 `ctx.documentPreviews.register`:现有 Molbio tab 已有文件列表、canvas 与错误态;
71
+ 新增 keyed 容器换不来新的用户能力。
72
+ - **不做**:卡通 ribbon、分子表面、静电势、对称、V3000、多 model 动画、测量工具。
73
+
74
+ ## [0.24.1] — 2026-11-08(输入边界与数据安全加固;工具数不变)
75
+
76
+ **不新增工具:工具数保持 64,benchmark 保持 65 题 / 64 覆盖。** 这一版只修“已经在产品里、
77
+ 但不会报错的错”。面板包 `0.1.9 → 0.1.10`:两个客户端产物因 `seqio.mjs` /
78
+ `protein-structure.mjs` 的加固改动而重建,按仓库规则必须升版本号,否则会用同一版本发不同字节。
79
+
80
+ ### 1. 数据安全与路径语义
81
+
82
+ - `molbio_paper_export_bibtex`:自定义 `file` 不以 `.json` 结尾时,旧逻辑把库路径本身当
83
+ 输出路径,BibTeX 会**覆盖文献库**。现在始终派生独立的 `.bib`(`refs.txt` →
84
+ `refs.txt.bib`),库文件保持不变。
85
+ - 相对路径读取改为按**会话工作区**解析(`sandboxPolicy.resolve(...).workspaceRoot`),
86
+ 不再落到 provider 的 `process.cwd()`;绝对路径行为不变。
87
+ - 文献库/记录库加载时拒绝 `null` 或标量条目,返回带文件名的输入错误,而不是
88
+ `paperKey()` / `record.name.trim()` 抛出的原始 `TypeError`;协议重命名与新增使用
89
+ 同一条重名规则;`molbio_paper_update` 在标题/URL/PMID 变化后重算派生 id。
90
+ - `molbio_batch_analyze`:显式 `paths` 也受 `max_files` 约束;无法解析的路径变成一条
91
+ 错误行而不是整批失败;glob 先转义反斜杠,`.` 不再被当通配符(`a.fa` 不会匹配
92
+ `aXfa`)。
93
+
94
+ ### 2. 并发声明与大输入
95
+
96
+ - 四个总会写默认 SVG 的工具——`molbio_helical_wheel`、`molbio_hydropathy_plot`、
97
+ `molbio_protein_charge`、`molbio_dotplot`——以及带 `output_path` 的
98
+ `molbio_fasta_fastq extract` 改为 `safe: false`。旧谓词在 `output_path` 省略时
99
+ 声称并发安全,而默认文件仍会被并发调用互相覆盖。
100
+ - 图表/FASTQ/蛋白剖面不再把用户规模数组展开进 `Math.min(...)`/`Math.max(...)`:
101
+ 200,000 行表格、130,000 条 reads 与长蛋白在文档声明的上限内不再抛 `RangeError`;
102
+ FASTQ 汇总改为单次流式计数,不再物化全部 Phred 值。
103
+ - `molbio_pubmed_abstract` 的 efetch 传输失败改为具名输入错误,不再让原始异常逃出工具。
104
+
105
+ ### 3. 发布与仓库卫生
106
+
107
+ - `.github/workflows/test.yml` 增加 `permissions: contents: read`;
108
+ `.gitignore` 忽略 `.codex-tmp/`。
109
+ - `preset/install.mjs` 校验 `--profile`(字母/数字开头,仅 `._-`),避免 profile 名
110
+ 经 Windows shell 时被当作命令元字符。
111
+ - 同步多处注释里过时的工具数(57/60/62 → 64)。
112
+
113
+ ### 4. 验证与边界
114
+
115
+ - `npm test` 全绿:smoke 64 工具、benchmark 65 题 / 64 覆盖、`preset-health` 29 行、
116
+ `release` 13 项;`build/client-bundle.mjs --check` 与 `build/preset-patch.mjs --check`
117
+ 与源一致。
118
+ - 不新增工具、不改 preset 行清单、不改 DSH 基线(0.2.2-rc.1)。
119
+ - `benchmark-changed` 的 POSIX 路径断言在本地沙箱因 Node 无法 spawn `git` 未执行;
120
+ CI 会执行。这是沙箱限制,不是仓库缺陷。
121
+
13
122
  ## [0.24.0] — 2026-11-05(载体/接头污染筛查;FASTQ 修剪;接头表静默错修复)
14
123
 
15
124
  **工具数 62 → 64,benchmark 63 → 65 题(64/64 覆盖)。** 两块工作都来自候选池 survey:
package/README.md CHANGED
@@ -383,7 +383,7 @@ mutations remain deferred")。所以插件无法在工作区里合法地落一
383
383
 
384
384
  | 落点 | 内容 |
385
385
  | --- | --- |
386
- | **Molbio**(右栏 tab) | 列出当前会话工作区的序列文件,选中**当场在面板里画出来**:`.dna`/`.gb`/`.gbk` 出质粒图谱 + 特征表,`.fa`/`.fasta` 出序列标识图 |
386
+ | **Molbio**(右栏 tab) | 列出当前会话工作区的序列与结构文件,选中**当场在面板里画出来**:`.dna`/`.gb`/`.gbk` 出质粒图谱 + 特征表,`.fa`/`.fasta` 出序列标识图,`.pdb`/`.cif`/`.sdf` 等出可旋转的 3D ball-and-stick / space-filling 模型 |
387
387
  | **Papers**(右栏 tab) | 把 `papers.json` 渲染成可搜索的阅读列表(标题直达 PubMed/原 URL) |
388
388
  | **图谱调用卡**(对话内) | 画图谱的工具**直接在对话里画出这次产出的图谱**,不必再去开文件 |
389
389
 
@@ -767,6 +767,8 @@ dsh-molbio-tools/
767
767
  ├── methylation.mjs # 甲基化敏感位点检查 + 双酶切 buffer 兼容
768
768
  ├── protein.mjs # 蛋白性质 / 肽段酶切 / 密码子优化
769
769
  ├── protein-structure.mjs # 螺旋轮与疏水性图
770
+ ├── structure-preview.mjs # PDB/mmCIF/SDF 解析器(浏览器面板)
771
+ ├── structure-render.mjs # 软件光栅化 3D ball-and-stick / space-filling 渲染器
770
772
  ├── protein-charge.mjs # 电荷-pH 滴定曲线 + pI(bjellqvist/emboss 两套 pK)
771
773
  ├── genbank.mjs # GenBank flatfile 解析器
772
774
  ├── snapgene.mjs # SnapGene .dna 二进制解析器
package/batch.mjs CHANGED
@@ -46,7 +46,11 @@ export function detectBatchFormat(path, text) {
46
46
  /** Convert a simple basename glob (*, ?) to a RegExp. */
47
47
  export function globToRegExp(pattern) {
48
48
  const source = String(pattern)
49
- .replace(/[.+^$()|[\]{}]/g, '\$&')
49
+ // A literal backslash must be escaped before anything else; otherwise the
50
+ // following replaceAll calls turn it into a regex escape instead of a
51
+ // filename character.
52
+ .replaceAll('\\', '\\\\')
53
+ .replace(/[.+^$()|[\]{}]/g, '\\$&')
50
54
  .replaceAll('*', '.*')
51
55
  .replaceAll('?', '.');
52
56
  return new RegExp('^' + source + '$', 'i');
@@ -19,9 +19,10 @@
19
19
  * consequences of a helper module being imported by feature modules — a real
20
20
  * dependency, but not the tool's IMPLEMENTATION.
21
21
  *
22
- * Measured, not guessed: 26 of the 57 tools call bindings from more than one
23
- * module. So a per-file → tool map is not a partition, it is a graph, and the
24
- * honest model is:
22
+ * Measured, not guessed: 26 of the tools call bindings from more than one
23
+ * module (measured when the suite covered 57; the graph property, not the
24
+ * count, is what matters). So a per-file → tool map is not a partition, it is
25
+ * a graph, and the honest model is:
25
26
  *
26
27
  * changed file → the module(s) it belongs to
27
28
  * → every module that transitively depends on them
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * ── Why a hand-built profile exists at all ──────────────────────────────────
7
7
  *
8
- * The model-facing way to get the 57 tools is the "Molecular Biology Lab" agent
8
+ * The model-facing way to get the 64 tools is the "Molecular Biology Lab" agent
9
9
  * preset, and the benchmark would obviously rather run THAT than a
10
10
  * reconstruction of it. It cannot: `dsh-headless` refuses a preset for a
11
11
  * structural reason, not a configuration one —
@@ -5,12 +5,13 @@
5
5
  * re-exported from the SAME source files the Node plugin ships, so the browser
6
6
  * bundle can never drift from the plugin's behaviour.
7
7
  *
8
- * Only these modules are browser-safe (they import nothing but `./lib.mjs`,
9
- * which is pure computation): lib, genbank, snapgene, plasmid, msa, logo,
10
- * crispr, align, seqio, plot, design. The Node-only ones (view, papers,
11
- * records, sanger, protein is fine but unused here, index) are deliberately
12
- * absent — importing them would drag `node:child_process` and friends into the
13
- * browser bundle, which the bundler rejects loudly.
8
+ * Only these modules are browser-safe (they import nothing but `./lib.mjs` and
9
+ * `./svgio.mjs`, which are pure computation): lib, genbank, snapgene, plasmid,
10
+ * msa, logo, crispr, align, seqio, plot, design, protein-structure,
11
+ * structure-preview, structure-render. The Node-only ones (view, papers, records,
12
+ * sanger, index)
13
+ * are deliberately absent — importing them would drag `node:child_process` and
14
+ * friends into the browser bundle, which the bundler rejects loudly.
14
15
  */
15
16
  export {
16
17
  DNA_BASES,
@@ -53,4 +54,13 @@ export {
53
54
  export { designTaqmanProbes, evaluateProbe, probeCandidates } from '../taqman.mjs';
54
55
  export { checkMultiplex } from '../multiplex.mjs';
55
56
  export { helicalWheel, hydropathyProfile, renderHelicalWheel, renderHydropathyPlot } from '../protein-structure.mjs';
57
+ export { STRUCTURE_EXTENSIONS, parseStructureText, structureFormatOf } from '../structure-preview.mjs';
58
+ export {
59
+ DEFAULT_STRUCTURE_VIEW,
60
+ MAX_RENDER_ATOMS,
61
+ STRUCTURE_ELEMENT_COLORS,
62
+ STRUCTURE_REPRESENTATIONS,
63
+ inferStructureBonds,
64
+ renderStructureImage,
65
+ } from '../structure-render.mjs';
56
66
  export { analyzeMethylation, bufferReport, planDoubleDigest } from '../methylation.mjs';
@@ -79,7 +79,10 @@ function parseModule(source, path) {
79
79
  for (const match of source.matchAll(EXPORT_LIST_RE)) {
80
80
  for (const name of parseNames(match[1], path)) exports.push({ local: name.local, source: null });
81
81
  }
82
- for (const name of reexports) exports.push({ local: name.local, source: name.specifier });
82
+ // The imported name is what the target namespace must be read for; dropping
83
+ // it emitted `exports.x = require(target).undefined` for EVERY re-export,
84
+ // which silently broke browser-api.mjs while the format checks stayed green.
85
+ for (const name of reexports) exports.push({ local: name.local, imported: name.imported, source: name.specifier });
83
86
  const listCount = (source.match(EXPORT_LIST_RE) ?? []).length;
84
87
  const reexportCount = (source.match(REEXPORT_RE) ?? []).length;
85
88
  const keywordCount = (source.match(/^export\s/gm) ?? []).length;
@@ -40,7 +40,7 @@ const checkOnly = process.argv.slice(2).includes('--check');
40
40
  /**
41
41
  * The bundles to emit. The SAME browser half ships through two channels:
42
42
  *
43
- * - `dsh-molbio-tools` — the plugin package: one install gives the 57 tools
43
+ * - `dsh-molbio-tools` — the plugin package: one install gives the 64 tools
44
44
  * plus the panel (its host half also registers the tools).
45
45
  * - `dsh-molbio-panel` — a panel-only package: installing it adds the tab to a
46
46
  * profile WITHOUT dragging the tools into every session of that profile,
@@ -53,9 +53,14 @@ import {
53
53
  parseAlignmentFile,
54
54
  parseLibrary,
55
55
  parsePlasmidFile,
56
+ parseStructureFile,
56
57
  plasmidSvg,
57
58
  readWorkspaceText,
58
59
  sortEntries,
60
+ structureImage,
61
+ DEFAULT_STRUCTURE_VIEW,
62
+ STRUCTURE_ELEMENT_COLORS,
63
+ STRUCTURE_REPRESENTATIONS,
59
64
  } from './panel-core.mjs';
60
65
 
61
66
  /** This implementation's identity, and the key its body/title register under. */
@@ -135,6 +140,20 @@ const styles = {
135
140
  error: { color: 'var(--dsh-danger, #c0392b)', padding: '10px 12px', whiteSpace: 'pre-wrap' },
136
141
  muted: { opacity: 0.6, padding: '10px 12px' },
137
142
  svg: { maxWidth: '100%', height: 'auto' },
143
+ canvas: { maxWidth: '100%', height: 'auto', display: 'block', border: '1px solid var(--dsh-border, #e3e6ea)', borderRadius: '6px', cursor: 'grab', touchAction: 'none', background: '#f7f9fb' },
144
+ button: (active) => ({
145
+ fontSize: '11px',
146
+ padding: '2px 8px',
147
+ cursor: 'pointer',
148
+ border: '1px solid var(--dsh-border, #e3e6ea)',
149
+ borderRadius: '4px',
150
+ background: active ? 'var(--dsh-selected, #eaf2ff)' : 'transparent',
151
+ fontWeight: active ? 600 : 400,
152
+ color: 'inherit',
153
+ }),
154
+ legend: { display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '8px', fontSize: '11px', opacity: 0.75 },
155
+ legendItem: { display: 'inline-flex', alignItems: 'center', gap: '4px' },
156
+ legendDot: { width: '9px', height: '9px', borderRadius: '50%', display: 'inline-block' },
138
157
  table: { borderCollapse: 'collapse', marginTop: '12px', fontSize: '12px', width: '100%' },
139
158
  th: { textAlign: 'left', padding: '4px 8px', borderBottom: '1px solid var(--dsh-border, #e3e6ea)', opacity: 0.7, fontWeight: 600 },
140
159
  td: { padding: '3px 8px', borderBottom: '1px solid var(--dsh-border-soft, #f0f2f5)' },
@@ -147,7 +166,11 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
147
166
  const [selected, setSelected] = useState(null);
148
167
  const [record, setRecord] = useState(null);
149
168
  const [status, setStatus] = useState({ kind: cwd === undefined ? 'waiting' : 'loading' });
169
+ const [view, setView] = useState({ ...DEFAULT_STRUCTURE_VIEW });
170
+ const [dragging, setDragging] = useState(false);
150
171
  const svgHost = useRef(null);
172
+ const canvasHost = useRef(null);
173
+ const drag = useRef(null);
151
174
 
152
175
  // Directory listing, re-read when the session's workspace root changes.
153
176
  useEffect(() => {
@@ -177,8 +200,13 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
177
200
  const kind = classifyEntry(selected);
178
201
  const parsed = kind === 'plasmid'
179
202
  ? { kind, ...parsePlasmidFile(selected.name, bytes) }
180
- : { kind, ...parseAlignmentFile(bytes) };
203
+ : kind === 'alignment'
204
+ ? { kind, ...parseAlignmentFile(bytes) }
205
+ : { kind, ...parseStructureFile(selected.name, bytes) };
181
206
  setRecord(parsed);
207
+ // A new structure starts from the same deterministic camera; the
208
+ // previous file's yaw/pitch would be meaningless on a different molecule.
209
+ if (parsed.kind === 'structure') setView({ ...DEFAULT_STRUCTURE_VIEW });
182
210
  setStatus({ kind: 'ready' });
183
211
  })
184
212
  .catch((error) => {
@@ -198,7 +226,7 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
198
226
  const host = svgHost.current;
199
227
  if (host === null) return;
200
228
  host.replaceChildren();
201
- if (record === null || status.kind !== 'ready') return;
229
+ if (record === null || status.kind !== 'ready' || record.kind === 'structure') return;
202
230
  // The renderer refuses what it cannot draw (renderPlasmidMap caps a map at
203
231
  // 200 features). Letting that throw out of the effect leaves the pane empty
204
232
  // with no explanation, so it becomes the pane's error state instead.
@@ -219,15 +247,90 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
219
247
  host.append(document.importNode(svg, true));
220
248
  }, [record, status.kind]);
221
249
 
250
+ // Structures are rasterized into an RGB buffer and painted into a canvas.
251
+ // The renderer is deterministic and shared with the Node tests; only the
252
+ // canvas plumbing lives here. During a drag the frame drops to 1x
253
+ // supersampling and returns to 2x when the pointer is released.
254
+ useEffect(() => {
255
+ if (record === null || record.kind !== 'structure' || status.kind !== 'ready') return;
256
+ const canvas = canvasHost.current;
257
+ if (canvas === null || typeof canvas.getContext !== 'function') return;
258
+ const context = canvas.getContext('2d');
259
+ if (context === null || typeof context.createImageData !== 'function') return;
260
+ const width = Number.isInteger(canvas.width) ? canvas.width : 640;
261
+ const height = Number.isInteger(canvas.height) ? canvas.height : 480;
262
+ let image;
263
+ try {
264
+ image = structureImage(record, {
265
+ width,
266
+ height,
267
+ yaw: view.yaw,
268
+ pitch: view.pitch,
269
+ zoom: view.zoom,
270
+ representation: view.representation,
271
+ // Large assemblies stay at interaction quality even when settled;
272
+ // 2× sampling is reserved for the small structures it visibly helps.
273
+ supersample: dragging || record.atoms.length > 4000 ? 1 : 2,
274
+ });
275
+ } catch (error) {
276
+ setStatus({ kind: 'error', message: String(error?.message ?? error) });
277
+ return;
278
+ }
279
+ const imageData = context.createImageData(width, height);
280
+ for (let source = 0, target = 0; source < image.rgb.length; source += 3, target += 4) {
281
+ imageData.data[target] = image.rgb[source];
282
+ imageData.data[target + 1] = image.rgb[source + 1];
283
+ imageData.data[target + 2] = image.rgb[source + 2];
284
+ imageData.data[target + 3] = 255;
285
+ }
286
+ context.putImageData(imageData, 0, 0);
287
+ }, [record, status.kind, view, dragging]);
288
+
289
+ const startDrag = (event) => {
290
+ if (typeof event?.currentTarget?.setPointerCapture === 'function' && event.pointerId !== undefined) {
291
+ try {
292
+ event.currentTarget.setPointerCapture(event.pointerId);
293
+ } catch {
294
+ // A synthetic pointer without capture support is still draggable.
295
+ }
296
+ }
297
+ drag.current = { x: event.clientX, y: event.clientY, yaw: view.yaw, pitch: view.pitch };
298
+ setDragging(true);
299
+ };
300
+
301
+ const moveDrag = (event) => {
302
+ const start = drag.current;
303
+ if (start === null) return;
304
+ const yaw = start.yaw + (event.clientX - start.x) * 0.01;
305
+ const pitch = Math.max(-1.4, Math.min(1.4, start.pitch - (event.clientY - start.y) * 0.01));
306
+ setView((current) => ({ ...current, yaw, pitch }));
307
+ };
308
+
309
+ const endDrag = () => {
310
+ drag.current = null;
311
+ setDragging(false);
312
+ };
313
+
314
+ const zoomView = (event) => {
315
+ // preventDefault keeps the sidebar from scrolling while the wheel zooms.
316
+ if (typeof event.preventDefault === 'function') event.preventDefault();
317
+ setView((current) => ({
318
+ ...current,
319
+ zoom: Math.max(0.35, Math.min(5, current.zoom * Math.exp(-Number(event.deltaY ?? 0) * 0.001))),
320
+ }));
321
+ };
322
+
323
+ const resetView = () => setView({ ...DEFAULT_STRUCTURE_VIEW });
324
+
222
325
  const openable = useMemo(() => entries.filter(isOpenable), [entries]);
223
326
 
224
- const head = h('div', { style: styles.listHead }, 'Sequence files');
327
+ const head = h('div', { style: styles.listHead }, 'Sequence & structure files');
225
328
  const list = h(
226
329
  'div',
227
330
  { style: styles.list },
228
331
  head,
229
332
  ...(openable.length === 0
230
- ? [h('div', { key: 'none', style: styles.muted }, 'No .dna / .gb / .gbk / .fasta files in this workspace')]
333
+ ? [h('div', { key: 'none', style: styles.muted }, 'No .dna / .gb / .gbk / .fasta / .pdb / .cif / .sdf files in this workspace')]
231
334
  : openable.map((entry) => {
232
335
  const kind = classifyEntry(entry);
233
336
  return h(
@@ -282,6 +385,40 @@ function MolbioPanel({ sessionId, remote, useSessions }) {
282
385
  h('span', null, `${record.rows[0]?.length ?? 0} aligned columns`)));
283
386
  body.push(h('div', { key: 'svg', ref: svgHost, style: styles.svg }));
284
387
  }
388
+ if (status.kind === 'ready' && record !== null && record.kind === 'structure') {
389
+ body.push(h('div', { key: 'meta', style: styles.meta },
390
+ h('span', null, record.title === '' ? record.name : record.title),
391
+ h('span', null, record.format.toUpperCase()),
392
+ h('span', null, `${record.atoms.length} atoms`),
393
+ h('span', null, `${record.chain_count} chain(s)`),
394
+ h('span', null, `${record.residue_count} residue(s)`)));
395
+ body.push(h('div', { key: 'controls', style: styles.meta },
396
+ ...STRUCTURE_REPRESENTATIONS.map((representation) => h('button', {
397
+ key: representation,
398
+ style: styles.button(view.representation === representation),
399
+ onClick: () => setView((current) => ({ ...current, representation })),
400
+ }, representation === 'ball-and-stick' ? 'Ball & stick' : 'Space filling')),
401
+ h('button', { key: 'reset', style: styles.button(false), onClick: resetView }, 'Reset view')));
402
+ body.push(h('canvas', {
403
+ key: 'canvas',
404
+ ref: canvasHost,
405
+ width: 640,
406
+ height: 480,
407
+ style: styles.canvas,
408
+ onPointerDown: startDrag,
409
+ onPointerMove: moveDrag,
410
+ onPointerUp: endDrag,
411
+ onPointerCancel: endDrag,
412
+ onWheel: zoomView,
413
+ onDoubleClick: resetView,
414
+ 'aria-label': '3D structure preview; drag to rotate, wheel to zoom',
415
+ }));
416
+ body.push(h('div', { key: 'legend', style: styles.legend },
417
+ ...['C', 'N', 'O', 'S', 'P'].map((element) => h('span', { key: element, style: styles.legendItem },
418
+ h('span', { style: { ...styles.legendDot, background: STRUCTURE_ELEMENT_COLORS[element] } }),
419
+ element))));
420
+ 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.'));
421
+ }
285
422
 
286
423
  return h('div', { style: styles.root }, list, h('div', { style: styles.main }, ...body));
287
424
  }
@@ -18,6 +18,12 @@ import {
18
18
  columnComposition,
19
19
  renderSequenceLogo,
20
20
  progressiveAlign,
21
+ parseStructureText,
22
+ renderStructureImage,
23
+ DEFAULT_STRUCTURE_VIEW,
24
+ STRUCTURE_ELEMENT_COLORS,
25
+ STRUCTURE_EXTENSIONS,
26
+ STRUCTURE_REPRESENTATIONS,
21
27
  } from './browser-api.mjs';
22
28
 
23
29
  /** Extensions the panel can open, mapped to the parser that handles them. */
@@ -36,6 +42,9 @@ export const ALIGNMENT_EXTENSIONS = {
36
42
  fas: 'fasta',
37
43
  };
38
44
 
45
+ /** Structure formats the panel opens, mapped to the parser kind. */
46
+ export { DEFAULT_STRUCTURE_VIEW, STRUCTURE_ELEMENT_COLORS, STRUCTURE_EXTENSIONS, STRUCTURE_REPRESENTATIONS };
47
+
39
48
  /** Lowercase extension without the dot, or '' when there is none. */
40
49
  export function extensionOf(name) {
41
50
  const match = /\.([A-Za-z0-9]+)$/.exec(String(name));
@@ -45,7 +54,7 @@ export function extensionOf(name) {
45
54
  /**
46
55
  * How the panel should treat one workspace entry.
47
56
  * @param {{name?: string, type?: string}} entry a `workspaceFiles.list` row.
48
- * @returns {'plasmid'|'alignment'|'other'|'directory'}
57
+ * @returns {'plasmid'|'alignment'|'structure'|'other'|'directory'}
49
58
  */
50
59
  export function classifyEntry(entry) {
51
60
  if (entry === null || typeof entry !== 'object') return 'other';
@@ -54,18 +63,19 @@ export function classifyEntry(entry) {
54
63
  const extension = extensionOf(entry.name ?? '');
55
64
  if (PLASMID_EXTENSIONS[extension] !== undefined) return 'plasmid';
56
65
  if (ALIGNMENT_EXTENSIONS[extension] !== undefined) return 'alignment';
66
+ if (STRUCTURE_EXTENSIONS[extension] !== undefined) return 'structure';
57
67
  return 'other';
58
68
  }
59
69
 
60
70
  /** Whether the panel can open this entry at all. */
61
71
  export function isOpenable(entry) {
62
72
  const kind = classifyEntry(entry);
63
- return kind === 'plasmid' || kind === 'alignment';
73
+ return kind === 'plasmid' || kind === 'alignment' || kind === 'structure';
64
74
  }
65
75
 
66
- /** Sort entries: plasmid files first, then alignments, then the rest, by name. */
76
+ /** Sort entries: plasmid files first, then alignments, structures, the rest. */
67
77
  export function sortEntries(entries) {
68
- const rank = { plasmid: 0, alignment: 1, other: 2, directory: 3 };
78
+ const rank = { plasmid: 0, alignment: 1, structure: 2, other: 3, directory: 4 };
69
79
  return [...entries].sort((left, right) => {
70
80
  const byKind = rank[classifyEntry(left)] - rank[classifyEntry(right)];
71
81
  if (byKind !== 0) return byKind;
@@ -164,6 +174,44 @@ export function plasmidSvg(record, options = {}) {
164
174
  });
165
175
  }
166
176
 
177
+ /**
178
+ * Parse a structure file into the record the panel draws.
179
+ *
180
+ * The size guard is the same browser-specific cap as {@link parsePlasmidFile}:
181
+ * parsing runs on the UI thread, so a 200 MB PDB must be refused with a message
182
+ * rather than freeze the tab. The format comes from the extension, exactly as
183
+ * the Node tools choose their parser.
184
+ * @param {string} name file name.
185
+ * @param {Uint8Array} bytes raw file content.
186
+ * @returns {object} a parseStructureText record.
187
+ */
188
+ export function parseStructureFile(name, bytes) {
189
+ if (!(bytes instanceof Uint8Array)) throw new MolbioInputError('file content must be a Uint8Array');
190
+ const extension = extensionOf(name);
191
+ const format = STRUCTURE_EXTENSIONS[extension];
192
+ if (format === undefined) {
193
+ throw new MolbioInputError(`the panel does not open .${extension} structure files; use .pdb, .ent, .cif, .mmcif, .sdf or .mol`);
194
+ }
195
+ if (bytes.length > MAX_PANEL_FILE_BYTES) {
196
+ throw new MolbioInputError(
197
+ `${name} is ${(bytes.length / 1024 / 1024).toFixed(1)} MB; the panel opens structure files up to ${MAX_PANEL_FILE_BYTES / 1024 / 1024} MB`,
198
+ );
199
+ }
200
+ return parseStructureText(format, new TextDecoder('utf-8').decode(bytes), name);
201
+ }
202
+
203
+ /**
204
+ * Render a parsed structure to an RGB buffer for the canvas.
205
+ * @param {object} record a {@link parseStructureFile} result.
206
+ * @param {{width?: number, height?: number, yaw?: number, pitch?: number,
207
+ * zoom?: number, representation?: string, supersample?: 1|2}} [options]
208
+ * @returns {{width:number, height:number, rgb:Uint8Array, atoms:number,
209
+ * bonds:number, representation:string}}
210
+ */
211
+ export function structureImage(record, options = {}) {
212
+ return renderStructureImage(record, options);
213
+ }
214
+
167
215
  /**
168
216
  * Parse an alignment file (FASTA) into the rows the logo needs.
169
217
  * @param {Uint8Array} bytes raw FASTA content.
@@ -101,7 +101,7 @@ const ANCHOR = [
101
101
  '#',
102
102
  '# Keep the tool in the PRESET and only the anchor here. An earlier version of',
103
103
  "# this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),",
104
- "# which both registered all 60 tools globally and collided with the preset's own",
104
+ "# which both registered every tool globally and collided with the preset's own",
105
105
  '# copy of that row:',
106
106
  '#',
107
107
  '# broken: "tool-molbio (...): never started"',
@@ -41,7 +41,7 @@ import { gunzipSync } from 'node:zlib';
41
41
  * The two npm entries this repository publishes, in publish order.
42
42
  *
43
43
  * THEY DO NOT SHARE A VERSION LINE, and that is deliberate rather than an
44
- * oversight to be "fixed" here. The root package carries the 62 tools and moves
44
+ * oversight to be "fixed" here. The root package carries the 64 tools and moves
45
45
  * with every toolset release; `dsh-molbio-panel` carries only the browser panel
46
46
  * and moves when the panel's own artifact moves. The tag names the ROOT version
47
47
  * (`v0.19.0`), and each entry is checked against its own declared version —