dsh-molbio-panel 0.1.7 → 0.1.10

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/README.md CHANGED
@@ -12,12 +12,12 @@ DSH Web GUI 的**浏览器内 Molbio 面板**:右侧栏的一个 tab,列出
12
12
  面板必须走 **bundle 渠道**(`dsh.client` 双面包):preset 渠道挂不了客户端 UI
13
13
  (详见 `dsh-molbio-tools` 仓库的 `docs/client-pipeline-exploration.md`)。
14
14
 
15
- 而 `dsh-molbio-tools` 的宿主半边会注册 **46 个工具**——把它挂进一个共享的 Web profile,
15
+ 而 `dsh-molbio-tools` 的宿主半边会注册 **64 个工具**——把它挂进一个共享的 Web profile,
16
16
  等于给**该 profile 的每个会话**都塞进这些工具。所以面板单独成包:
17
17
 
18
18
  | 包 | 宿主半边 | 用途 |
19
19
  | --- | --- | --- |
20
- | `dsh-molbio-tools` | 46 个 molbio 工具 | 分子生物学专用 profile / preset |
20
+ | `dsh-molbio-tools` | 64 个 molbio 工具 | 分子生物学专用 profile / preset |
21
21
  | `dsh-molbio-panel` | **不注册任何工具**(空实现) | 只想在共享 profile 里加一个面板 |
22
22
 
23
23
  宿主半边刻意保持空:客户端扫描按 **Loader 条目**取包清单,条目只要能加载,浏览器半就能
package/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * dsh-molbio-panel — host half.
3
3
  *
4
4
  * This package exists so the browser panel can be installed WITHOUT dragging
5
- * the 46 molbio tools into every session of a profile. It is a `dsh.client`
5
+ * the molbio toolset into every session of a profile. It is a `dsh.client`
6
6
  * dual-face package: the host half below, and the browser half built from the
7
7
  * `dsh-molbio-tools` sources into `lib/client.js`.
8
8
  *
package/lib/client.js CHANGED
@@ -4964,12 +4964,35 @@ window.__ModuleLoader__.load({
4964
4964
  return entries.map((entry) => `>${entry.id}${entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}`).join('\n') + '\n';
4965
4965
  }
4966
4966
 
4967
+ /** Render entries as four-line FASTQ text (quality is preserved verbatim). */
4968
+ function toFastq(entries) {
4969
+ return entries.map((entry) => {
4970
+ if (typeof entry.quality !== 'string' || entry.quality.length !== entry.sequence.length) {
4971
+ throw new MolbioInputError(`entry ${entry.id}: sequence and quality lengths differ`);
4972
+ }
4973
+ return `@${entry.id}${entry.description !== undefined && entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}\n+\n${entry.quality}`;
4974
+ }).join('\n') + '\n';
4975
+ }
4976
+
4967
4977
  /** Summary statistics over parsed entries. */
4968
4978
  function entryStats(entries, qualityMode = false) {
4969
- const lengths = entries.map((entry) => entry.sequence.length);
4979
+ // Count in a loop instead of spreading into Math.min/Math.max: a large FASTQ
4980
+ // can hold hundreds of thousands of reads (and millions of quality
4981
+ // characters), and spreading such an array overflows the engine's argument
4982
+ // limit with a RangeError. The numbers are identical; only the failure mode
4983
+ // changes.
4984
+ let totalBases = 0;
4985
+ let minLength = entries.length === 0 ? 0 : Infinity;
4986
+ let maxLength = 0;
4987
+ let longest = 0;
4970
4988
  let gc = 0;
4971
4989
  let at = 0;
4972
4990
  for (const entry of entries) {
4991
+ const length = entry.sequence.length;
4992
+ totalBases += length;
4993
+ if (length < minLength) minLength = length;
4994
+ if (length > maxLength) maxLength = length;
4995
+ if (length > longest) longest = length;
4973
4996
  for (const base of entry.sequence) {
4974
4997
  if (base === 'G' || base === 'C') gc++;
4975
4998
  else if (base === 'A' || base === 'T') at++;
@@ -4977,22 +5000,34 @@ window.__ModuleLoader__.load({
4977
5000
  }
4978
5001
  const stats = {
4979
5002
  entries: entries.length,
4980
- total_bases: lengths.reduce((sum, len) => sum + len, 0),
4981
- min_length: Math.min(...lengths),
4982
- max_length: Math.max(...lengths),
4983
- mean_length: Math.round((lengths.reduce((sum, len) => sum + len, 0) / lengths.length) * 100) / 100,
5003
+ total_bases: totalBases,
5004
+ min_length: minLength,
5005
+ max_length: maxLength,
5006
+ mean_length: entries.length === 0 ? 0 : Math.round((totalBases / entries.length) * 100) / 100,
4984
5007
  gc_percent: gc + at === 0 ? 0 : Math.round((gc / (gc + at)) * 1000) / 10,
4985
5008
  };
4986
5009
  if (qualityMode) {
4987
- const allQ = entries.flatMap((entry) => [...entry.quality].map((ch) => ch.charCodeAt(0) - 33));
4988
- if (allQ.length > 0) {
4989
- const mean = allQ.reduce((sum, q) => sum + q, 0) / allQ.length;
4990
- const low = allQ.filter((q) => q < 20).length / allQ.length;
4991
- stats.quality_mean = Math.round(mean * 100) / 100;
4992
- stats.quality_min = Math.min(...allQ);
4993
- stats.quality_max = Math.max(...allQ);
4994
- stats.low_quality_fraction = Math.round(low * 1000) / 1000;
4995
- const maxLen = Math.min(150, Math.max(...entries.map((e) => e.sequence.length)));
5010
+ let qualitySum = 0;
5011
+ let qualityCount = 0;
5012
+ let lowCount = 0;
5013
+ let qualityMin = Infinity;
5014
+ let qualityMax = -Infinity;
5015
+ for (const entry of entries) {
5016
+ for (const ch of entry.quality) {
5017
+ const q = ch.charCodeAt(0) - 33;
5018
+ qualitySum += q;
5019
+ qualityCount++;
5020
+ if (q < 20) lowCount++;
5021
+ if (q < qualityMin) qualityMin = q;
5022
+ if (q > qualityMax) qualityMax = q;
5023
+ }
5024
+ }
5025
+ if (qualityCount > 0) {
5026
+ stats.quality_mean = Math.round((qualitySum / qualityCount) * 100) / 100;
5027
+ stats.quality_min = qualityMin;
5028
+ stats.quality_max = qualityMax;
5029
+ stats.low_quality_fraction = Math.round((lowCount / qualityCount) * 1000) / 1000;
5030
+ const maxLen = Math.min(150, longest);
4996
5031
  const perPosition = [];
4997
5032
  for (let pos = 0; pos < maxLen; pos++) {
4998
5033
  const values = entries.filter((e) => e.sequence.length > pos).map((e) => e.quality.charCodeAt(pos) - 33);
@@ -5008,6 +5043,7 @@ window.__ModuleLoader__.load({
5008
5043
  exports.parseFasta = parseFasta;
5009
5044
  exports.parseFastq = parseFastq;
5010
5045
  exports.toFasta = toFasta;
5046
+ exports.toFastq = toFastq;
5011
5047
  exports.entryStats = entryStats;
5012
5048
  return exports;
5013
5049
  };
@@ -5051,17 +5087,17 @@ window.__ModuleLoader__.load({
5051
5087
  * from `anchor` and not from the RC-scan `start`. It is the REVERSE primer,
5052
5088
  * and the amplicon ends at `anchor + length - 1`.
5053
5089
  *
5054
- * NOTE — `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
5055
- * deliberately does NOT follow this convention and is unchanged in 0.16.0: it
5056
- * reports both primers as sense substrings of the spliced transcript, which is
5057
- * what makes its `exon`/`junction_left`/`junction_right` geometry, its
5058
- * spliced-vs-genomic mismatch report and its 3'-tail mispriming check read
5059
- * uniformly on one sequence. Both ends are therefore reported 5'->3' on the
5060
- * sense strand, `forward` is downstream of `reverse`, and the molecule to order
5061
- * as the reverse primer is the reverse complement of the reported `reverse`.
5062
- * That is a real usability wart, not a silent one (nothing feeds this pair to
5063
- * `molbio_pcr_simulate`), so it is recorded on the roadmap rather than changed
5064
- * in the same release that moves `designPrimerPairs`.
5090
+ * `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
5091
+ * follows the SAME convention since 0.23.0. The junction is carried by the
5092
+ * REVERSE primer (the assay sits 5' of the junction), so the reverse candidate
5093
+ * is scanned on the reverse complement of the spliced transcript: every 3'-end
5094
+ * constraint is then evaluated on the molecule that is ordered, and the hit is
5095
+ * mapped back to sense coordinates through `anchor` (its 3' end, the LEFT edge
5096
+ * of its site). `forward` is upstream of `reverse`, the spliced amplicon runs
5097
+ * from the forward 5' end to the reverse 5' end, and the reported reverse
5098
+ * sequence is what goes on the order form. Before 0.23.0 the tool reported both
5099
+ * primers as sense substrings, so `forward` sat downstream of `reverse` and the
5100
+ * reported reverse was the reverse complement of the molecule to order.
5065
5101
  */
5066
5102
 
5067
5103
  const { DNA_BASES, MolbioInputError, baseCounts, complement, dimerThermo, endGcCount5, endStability5, findRuns, hairpinThermo, normalizeSequence, primerTm, reverseComplement, selfAnyScore, selfEndScore } = __molbio_require("lib.mjs");
@@ -5737,37 +5773,60 @@ window.__ModuleLoader__.load({
5737
5773
  // ── cross-intron primer design ──────────────────────────────────────────────
5738
5774
 
5739
5775
  /**
5740
- * Mismatch report for cross-intron candidates. Both primer scans run on the
5741
- * spliced transcript in the sense orientation (the reported reverse.sequence
5742
- * follows the established sense-substring convention of this tool), so the
5743
- * mapping is uniform: `template_base` is what a perfectly matching primer
5744
- * carries at the position on the spliced transcript, and coordinates come back
5745
- * both spliced and genomic.
5776
+ * Mismatch report for cross-intron candidates. The forward primer is the
5777
+ * sense-strand substring at its binding site; the reverse primer is the REVERSE
5778
+ * COMPLEMENT of the sense-strand substring at its site (the molecule that is
5779
+ * ordered). `anchorStart` is the 0-based sense-strand position of the primer's
5780
+ * 3' end — for the reverse primer that is the LEFT edge of its site — and
5781
+ * `isReverse` flips both the perfect-match base and how a primer offset maps
5782
+ * onto the transcript.
5746
5783
  */
5747
- function intronMismatchReport(candidate, spliced, splicedToGenomic) {
5784
+ function intronMismatchReport(candidate, spliced, splicedToGenomic, anchorStart, isReverse) {
5748
5785
  const len = candidate.length;
5749
- return candidate.mismatches.map(({ p, base }) => ({
5750
- position: p + 1,
5751
- template_base: spliced[candidate.start + p],
5752
- primer_base: base,
5753
- spliced_position: candidate.start + p + 1,
5754
- genomic_position: splicedToGenomic[candidate.start + p] + 1,
5755
- distance_from_3prime: len - 1 - p,
5756
- }));
5786
+ return candidate.mismatches.map(({ p, base }) => {
5787
+ const offset = isReverse ? len - 1 - p : p;
5788
+ const index = anchorStart + offset;
5789
+ return {
5790
+ position: p + 1,
5791
+ template_base: isReverse ? complement(spliced[index]) : spliced[index],
5792
+ primer_base: base,
5793
+ spliced_position: index + 1,
5794
+ genomic_position: splicedToGenomic[index] + 1,
5795
+ distance_from_3prime: len - 1 - p,
5796
+ };
5797
+ });
5757
5798
  }
5758
5799
 
5759
5800
  /**
5760
- * Design qPCR primer pairs where the forward primer spans an exon-exon
5761
- * junction (>= min_junction_bases on each side) so genomic DNA cannot be
5762
- * amplified, and the reverse primer sits in a different exon. Coordinates are
5763
- * reported both on the spliced transcript and on the genomic sequence.
5801
+ * The same weights as `mismatchPenalty`, over the snake_case report fields.
5802
+ * The old intron path handed snake_case reports to the camelCase helper, so the
5803
+ * 3'-zone surcharge never applied there; this keeps the two conventions apart on
5804
+ * purpose instead of loosening the shared helper.
5805
+ */
5806
+ function intronMismatchPenalty(report, zone) {
5807
+ return report.reduce((sum, mismatch) => sum + 8 + (mismatch.distance_from_3prime <= zone ? 4 : 0), 0);
5808
+ }
5809
+
5810
+ /**
5811
+ * Design qPCR primer pairs across an exon-exon junction so genomic DNA cannot
5812
+ * be amplified: the REVERSE primer straddles the junction (>= minJunctionBases
5813
+ * on each side), the FORWARD primer sits upstream inside a single exon, and the
5814
+ * spliced amplicon crosses the junction inside the reverse primer's site.
5815
+ *
5816
+ * Coordinates are reported both on the spliced transcript and on the genomic
5817
+ * sequence. Orientation follows `designPrimerPairs` (see the file header):
5818
+ * `forward.sequence` is the sense-strand substring at its site and
5819
+ * `reverse.sequence` is the reverse complement of the sense-strand substring at
5820
+ * its site. The reverse candidate is scanned on the reverse complement of the
5821
+ * spliced transcript, so every 3'-end constraint (GC clamp, end stability, end
5822
+ * GC, self-end, hairpin) applies to the molecule that is ordered.
5764
5823
  */
5765
5824
  function designIntronSpanningPrimers(genomic, exons, opts) {
5766
5825
  if (!Array.isArray(exons) || exons.length < 2) throw new MolbioInputError('exons must be an array of at least two {start, end} spans');
5767
5826
  const sorted = [...exons].sort((a, b) => a.start - b.start);
5768
5827
  for (const exon of sorted) {
5769
5828
  if (!Number.isInteger(exon.start) || !Number.isInteger(exon.end) || exon.start < 1 || exon.end > genomic.length || exon.start > exon.end) {
5770
- throw new MolbioInputError(`exon ${JSON.stringify(exon)} is outside the genomic sequence (length ${genomic.length})`);
5829
+ throw new MolbioInputError('exon ' + JSON.stringify(exon) + ' is outside the genomic sequence (length ' + genomic.length + ')');
5771
5830
  }
5772
5831
  }
5773
5832
  for (let i = 1; i < sorted.length; i++) {
@@ -5790,33 +5849,39 @@ window.__ModuleLoader__.load({
5790
5849
  }
5791
5850
  });
5792
5851
  if (spliced.length < 24) throw new MolbioInputError('the spliced transcript is too short for primer design');
5852
+ const splicedLength = spliced.length;
5853
+ const rcSpliced = reverseComplement(spliced);
5793
5854
 
5794
5855
  // v13 target position preference (coordinates on the spliced transcript).
5795
5856
  const targetPosition = opts.targetPosition;
5796
- if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > spliced.length)) {
5797
- throw new MolbioInputError(`target_position ${targetPosition} is outside the spliced transcript (length ${spliced.length})`);
5857
+ if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > splicedLength)) {
5858
+ throw new MolbioInputError('target_position ' + targetPosition + ' is outside the spliced transcript (length ' + splicedLength + ')');
5798
5859
  }
5799
5860
  const targetWeight = opts.targetPenalty ?? DESIGN_DEFAULTS.targetPenalty;
5800
5861
 
5801
- // Forward candidates must span a junction with minSide bases on each side.
5802
- const fwd = [];
5803
- for (const candidate of scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)) {
5862
+ // Forward candidates: entirely inside one exon on the sense strand.
5863
+ const fwd = scanCandidates(spliced, 0, splicedLength - opts.lenMin, opts)
5864
+ .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
5865
+ .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
5866
+ .sort((a, b) => a.start - b.start);
5867
+
5868
+ // Reverse candidates: scanned on the reverse complement (the ordered
5869
+ // molecule), then mapped back to the sense-strand 3' end `anchor`.
5870
+ const rev = [];
5871
+ for (const candidate of scanCandidates(rcSpliced, 0, splicedLength - opts.lenMin, opts)) {
5872
+ const anchor = splicedLength - (candidate.start + candidate.length);
5873
+ const topEnd = anchor + candidate.length - 1;
5804
5874
  for (const junction of junctionPositions) {
5805
- const left = junction.splicedPos - candidate.start;
5806
- const right = candidate.start + candidate.length - junction.splicedPos;
5875
+ const left = junction.splicedPos - anchor;
5876
+ const right = topEnd + 1 - junction.splicedPos;
5807
5877
  if (left >= minSide && right >= minSide) {
5808
- fwd.push({ ...candidate, junction, junction_left: left, junction_right: right });
5878
+ rev.push({ ...candidate, anchor, junction, junction_left: left, junction_right: right });
5809
5879
  break;
5810
5880
  }
5811
5881
  }
5812
5882
  }
5813
-
5814
- // Reverse candidates sit entirely inside one exon.
5815
- const rev = scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)
5816
- .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
5817
- .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
5818
- .sort((a, b) => a.start - b.start);
5819
- const revStarts = rev.map((candidate) => candidate.start);
5883
+ rev.sort((a, b) => a.anchor - b.anchor);
5884
+ const revAnchors = rev.map((candidate) => candidate.anchor);
5820
5885
 
5821
5886
  const tmCenter = (opts.tmMin + opts.tmMax) / 2;
5822
5887
  const misprimingIndex = opts.checkMispriming
@@ -5825,48 +5890,52 @@ window.__ModuleLoader__.load({
5825
5890
  const maxSites = opts.misprimingMaxSites ?? DESIGN_DEFAULTS.misprimingMaxSites;
5826
5891
  const pairs = [];
5827
5892
  for (const f of fwd) {
5828
- const fwdEnd = f.start + f.length - 1;
5829
- const aMin = fwdEnd - opts.ampliconMax + 1;
5830
- const aMax = fwdEnd - opts.ampliconMin + 1;
5893
+ // The reverse site must start after the forward 3' end, and the amplicon
5894
+ // (forward 5' -> reverse 5') must fit the window. The bounds are widened by
5895
+ // the primer-length range and the exact length is checked below, because a
5896
+ // long reverse primer can start earlier and still close inside the window.
5897
+ const aMin = Math.max(f.start + f.length, f.start + opts.ampliconMin - opts.lenMax);
5898
+ const aMax = f.start + opts.ampliconMax - opts.lenMin;
5899
+ if (aMax < aMin) continue;
5831
5900
  let lo = 0;
5832
- let hi = revStarts.length;
5901
+ let hi = revAnchors.length;
5833
5902
  while (lo < hi) {
5834
5903
  const mid = (lo + hi) >> 1;
5835
- if (revStarts[mid] < aMin) lo = mid + 1;
5904
+ if (revAnchors[mid] < aMin) lo = mid + 1;
5836
5905
  else hi = mid;
5837
5906
  }
5838
5907
  for (let i = lo; i < rev.length; i++) {
5839
5908
  const r = rev[i];
5840
- if (r.start > aMax) break;
5841
- if (r.exon === f.junction.down) continue; // reverse primer must sit in a different exon
5842
- const gEnd = splicedToGenomic[fwdEnd];
5843
- const gStart = splicedToGenomic[r.start];
5909
+ if (r.anchor > aMax) break;
5910
+ const ampliconStart = f.start;
5911
+ const ampliconEnd = r.anchor + r.length - 1;
5912
+ const ampliconLength = ampliconEnd - ampliconStart + 1;
5913
+ if (ampliconLength < opts.ampliconMin || ampliconLength > opts.ampliconMax) continue;
5914
+ const gStart = splicedToGenomic[ampliconStart];
5915
+ const gEnd = splicedToGenomic[ampliconEnd];
5844
5916
  if (gEnd - gStart + 1 < minGenomicSpan) continue;
5845
5917
  if (Math.abs(f.tm - r.tm) > opts.maxTmDelta) continue;
5846
5918
  const dimer = dimerThermo(f.sequence, r.sequence, opts.ctMolar ?? DEFAULT_CT_MOLAR);
5847
5919
  if (dimer.any_tm > opts.maxDimerTm || dimer.end_tm > opts.maxDimerEndTm) continue;
5920
+ const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
5848
5921
  let fMispriming = { count: 0, sites: [] };
5849
5922
  let rMispriming = { count: 0, sites: [] };
5850
5923
  if (misprimingIndex !== undefined) {
5851
- const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
5852
- fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start);
5924
+ // Exclude each primer's own 3'-tail window (not its 5' end).
5925
+ fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start + f.length - misK);
5853
5926
  if (fMispriming.count > maxSites) continue;
5854
- // the reported reverse sequence follows the sense-substring convention:
5855
- // the real oligo is its reverse complement, so its 3' tail is the RC
5856
- // of the FIRST k bases and the intended site is r.start
5857
- const rTail = reverseComplement(r.sequence.slice(0, misK));
5858
- rMispriming = misprimingForCandidate(r, rTail, misprimingIndex, opts, r.start);
5927
+ rMispriming = misprimingForCandidate(r, r.sequence.slice(-misK), misprimingIndex, opts, r.anchor);
5859
5928
  if (rMispriming.count > maxSites) continue;
5860
5929
  }
5861
- const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic);
5862
- const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic);
5930
+ const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic, f.start, false);
5931
+ const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic, r.anchor, true);
5863
5932
  const zone = opts.mismatch3PrimeZone ?? DESIGN_DEFAULTS.mismatch3PrimeZone;
5864
5933
  let fTargetDistance;
5865
5934
  let rTargetDistance;
5866
5935
  let targetDistance;
5867
5936
  if (targetPosition !== undefined) {
5868
- fTargetDistance = Math.abs(f.start + f.length - targetPosition); // spliced 1-based 3' end
5869
- rTargetDistance = Math.abs(r.start + 1 - targetPosition);
5937
+ fTargetDistance = Math.abs(f.start + f.length - targetPosition); // forward 3' end (1-based)
5938
+ rTargetDistance = Math.abs(r.anchor + 1 - targetPosition); // reverse 3' end (1-based)
5870
5939
  targetDistance = Math.min(fTargetDistance, rTargetDistance);
5871
5940
  }
5872
5941
  const penalty = 0.6 * Math.abs(f.tm - r.tm) + Math.abs(f.tm - tmCenter) + Math.abs(r.tm - tmCenter)
@@ -5874,8 +5943,8 @@ window.__ModuleLoader__.load({
5874
5943
  + 1.0 * Math.max(0, f.self_end - 1) + 1.0 * Math.max(0, r.self_end - 1)
5875
5944
  + 0.2 * Math.max(0, f.hairpin_tm - 40) + 0.2 * Math.max(0, r.hairpin_tm - 40)
5876
5945
  + 0.2 * Math.max(0, dimer.any_tm - 40) + 0.2 * Math.max(0, dimer.end_tm - 40)
5877
- + mismatchPenalty(fMismatches, zone)
5878
- + mismatchPenalty(rMismatches, zone)
5946
+ + intronMismatchPenalty(fMismatches, zone)
5947
+ + intronMismatchPenalty(rMismatches, zone)
5879
5948
  + 8 * fMispriming.count + 8 * rMispriming.count
5880
5949
  + (targetDistance !== undefined ? targetWeight * targetDistance : 0);
5881
5950
  pairs.push({
@@ -5893,9 +5962,7 @@ window.__ModuleLoader__.load({
5893
5962
  spliced_end: f.start + f.length,
5894
5963
  genomic_start: splicedToGenomic[f.start] + 1,
5895
5964
  genomic_end: splicedToGenomic[f.start + f.length - 1] + 1,
5896
- exons: [String(f.junction.up + 1), String(f.junction.down + 1)],
5897
- junction_left: f.junction_left,
5898
- junction_right: f.junction_right,
5965
+ exon: f.exon + 1,
5899
5966
  mismatch_count: fMismatches.length,
5900
5967
  mismatches: fMismatches,
5901
5968
  mispriming_count: fMispriming.count,
@@ -5917,11 +5984,13 @@ window.__ModuleLoader__.load({
5917
5984
  hairpin_tm: r.hairpin_tm,
5918
5985
  end_stability_kcal: r.end_stability_kcal,
5919
5986
  end_gc_count: r.end_gc_count,
5920
- spliced_start: r.start + 1,
5921
- spliced_end: r.start + r.length,
5922
- genomic_start: splicedToGenomic[r.start] + 1,
5923
- genomic_end: splicedToGenomic[r.start + r.length - 1] + 1,
5924
- exon: r.exon + 1,
5987
+ spliced_start: r.anchor + 1,
5988
+ spliced_end: r.anchor + r.length,
5989
+ genomic_start: splicedToGenomic[r.anchor] + 1,
5990
+ genomic_end: splicedToGenomic[r.anchor + r.length - 1] + 1,
5991
+ exons: [String(r.junction.up + 1), String(r.junction.down + 1)],
5992
+ junction_left: r.junction_left,
5993
+ junction_right: r.junction_right,
5925
5994
  mismatch_count: rMismatches.length,
5926
5995
  mismatches: rMismatches,
5927
5996
  mispriming_count: rMispriming.count,
@@ -5933,7 +6002,7 @@ window.__ModuleLoader__.load({
5933
6002
  })),
5934
6003
  ...(rTargetDistance !== undefined ? { target_distance: rTargetDistance } : {}),
5935
6004
  },
5936
- spliced_amplicon: { start: r.start + 1, end: fwdEnd + 1, length: fwdEnd - r.start + 1 },
6005
+ spliced_amplicon: { start: ampliconStart + 1, end: ampliconEnd + 1, length: ampliconLength },
5937
6006
  genomic_amplicon_length: gEnd - gStart + 1,
5938
6007
  ...(targetDistance !== undefined ? { target_distance: targetDistance } : {}),
5939
6008
  penalty: Math.round(penalty * 100) / 100,
@@ -5944,7 +6013,7 @@ window.__ModuleLoader__.load({
5944
6013
  const seen = new Set();
5945
6014
  const unique = [];
5946
6015
  for (const pair of pairs) {
5947
- const key = `${pair.spliced_amplicon.start}:${pair.spliced_amplicon.end}`;
6016
+ const key = pair.spliced_amplicon.start + ':' + pair.spliced_amplicon.end;
5948
6017
  if (seen.has(key)) continue;
5949
6018
  seen.add(key);
5950
6019
  unique.push(pair);
@@ -7093,12 +7162,27 @@ window.__ModuleLoader__.load({
7093
7162
  };
7094
7163
  const WATER = 18.015;
7095
7164
 
7096
- // Bjellqvist 1993 pK values (EMBOSS-style)
7097
- const PK = {
7098
- Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
7099
- K: 10.8, R: 12.0, Y: 10.3,
7165
+ /**
7166
+ * Selectable pK sets for the Henderson-Hasselbalch charge model. `bjellqvist`
7167
+ * is the set `proteinProperties` has always used for its pI; `emboss` is the
7168
+ * Epk.dat table EMBOSS ships with `iep`. They are NOT interchangeable — the
7169
+ * reported set name travels with every charge/pI value.
7170
+ */
7171
+ const PK_SETS = {
7172
+ bjellqvist: {
7173
+ label: 'Bjellqvist 1993',
7174
+ Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
7175
+ K: 10.8, R: 12.0, Y: 10.3,
7176
+ },
7177
+ emboss: {
7178
+ label: 'EMBOSS Epk.dat',
7179
+ Nterm: 8.6, Cterm: 3.6, C: 8.5, D: 3.9, E: 4.1, H: 6.5,
7180
+ K: 10.8, R: 12.5, Y: 10.1,
7181
+ },
7100
7182
  };
7101
7183
 
7184
+ const PK = PK_SETS.bjellqvist;
7185
+
7102
7186
  const HYDROPATHY = {
7103
7187
  A: 1.8, R: -4.5, N: -3.5, D: -3.5, C: 2.5, Q: -3.5, E: -3.5,
7104
7188
  G: -0.4, H: -3.2, I: 4.5, L: 3.8, K: -3.9, M: 1.9, F: 2.8,
@@ -7111,30 +7195,37 @@ window.__ModuleLoader__.load({
7111
7195
  return counts;
7112
7196
  }
7113
7197
 
7114
- function chargeAt(seq, counts, pH) {
7198
+ /**
7199
+ * Net charge at a given pH by Henderson-Hasselbalch summation. The terminal
7200
+ * groups count once each; K/R/H are positive, D/E/C/Y negative. `pkaSet` is a
7201
+ * key of PK_SETS (or a set object), so callers can name the table they used.
7202
+ */
7203
+ function netCharge(seq, counts, pH, pkaSet = 'bjellqvist') {
7204
+ const pk = typeof pkaSet === 'string' ? PK_SETS[pkaSet] : pkaSet;
7205
+ if (pk === undefined) throw new MolbioInputError('unknown pK set ' + JSON.stringify(pkaSet) + '; expected one of ' + Object.keys(PK_SETS).join(', '));
7115
7206
  let charge = 0;
7116
7207
  const pos = (pKa) => 1 / (1 + Math.pow(10, pH - pKa));
7117
7208
  const neg = (pKa) => 1 / (1 + Math.pow(10, pKa - pH));
7118
- charge += pos(PK.Nterm);
7119
- for (const [aa, pKa] of [['K', PK.K], ['R', PK.R], ['H', PK.H]]) {
7209
+ charge += pos(pk.Nterm);
7210
+ for (const [aa, pKa] of [['K', pk.K], ['R', pk.R], ['H', pk.H]]) {
7120
7211
  if (counts[aa] !== undefined) charge += counts[aa] * pos(pKa);
7121
7212
  }
7122
- charge -= neg(PK.Cterm);
7123
- for (const [aa, pKa] of [['D', PK.D], ['E', PK.E], ['C', PK.C], ['Y', PK.Y]]) {
7213
+ charge -= neg(pk.Cterm);
7214
+ for (const [aa, pKa] of [['D', pk.D], ['E', pk.E], ['C', pk.C], ['Y', pk.Y]]) {
7124
7215
  if (counts[aa] !== undefined) charge -= counts[aa] * neg(pKa);
7125
7216
  }
7126
7217
  return charge;
7127
7218
  }
7128
7219
 
7129
7220
  /** Isoelectric point by bisection over the net-charge function. */
7130
- function isoelectricPoint(seq, counts) {
7221
+ function isoelectricPoint(seq, counts, pkaSet = 'bjellqvist') {
7131
7222
  let lo = 0;
7132
7223
  let hi = 14;
7133
- let chargeLo = chargeAt(seq, counts, lo);
7134
- let chargeHi = chargeAt(seq, counts, hi);
7224
+ let chargeLo = netCharge(seq, counts, lo, pkaSet);
7225
+ let chargeHi = netCharge(seq, counts, hi, pkaSet);
7135
7226
  for (let i = 0; i < 60; i++) {
7136
7227
  const mid = (lo + hi) / 2;
7137
- const chargeMid = chargeAt(seq, counts, mid);
7228
+ const chargeMid = netCharge(seq, counts, mid, pkaSet);
7138
7229
  if (Math.abs(chargeMid) < 1e-4) return Math.round(mid * 100) / 100;
7139
7230
  if (Math.sign(chargeMid) === Math.sign(chargeLo)) {
7140
7231
  lo = mid;
@@ -7439,6 +7530,9 @@ window.__ModuleLoader__.load({
7439
7530
  }
7440
7531
 
7441
7532
  exports.normalizeProtein = normalizeProtein;
7533
+ exports.PK_SETS = PK_SETS;
7534
+ exports.countAminoAcids = countAminoAcids;
7535
+ exports.netCharge = netCharge;
7442
7536
  exports.isoelectricPoint = isoelectricPoint;
7443
7537
  exports.proteinProperties = proteinProperties;
7444
7538
  exports.peptideDigest = peptideDigest;
@@ -7728,14 +7822,22 @@ window.__ModuleLoader__.load({
7728
7822
  }
7729
7823
  if (current !== null) peaks.push(current);
7730
7824
  const total = points.reduce((sum, point) => sum + point.hydropathy, 0);
7825
+ // Loop rather than Math.min(...points): the protein length is not capped and
7826
+ // a long input would overflow the argument limit with a RangeError.
7827
+ let minimumHydropathy = Infinity;
7828
+ let maximumHydropathy = -Infinity;
7829
+ for (const point of points) {
7830
+ if (point.hydropathy < minimumHydropathy) minimumHydropathy = point.hydropathy;
7831
+ if (point.hydropathy > maximumHydropathy) maximumHydropathy = point.hydropathy;
7832
+ }
7731
7833
  return {
7732
7834
  length: sequence.length,
7733
7835
  window,
7734
7836
  threshold,
7735
7837
  gravy: sequence.length === 0 ? 0 : round2([...sequence].reduce((sum, aa) => sum + KYTE_DOOLITTLE[aa], 0) / sequence.length),
7736
7838
  mean_profile: round3(total / points.length),
7737
- minimum_hydropathy: round3(Math.min(...points.map((point) => point.hydropathy))),
7738
- maximum_hydropathy: round3(Math.max(...points.map((point) => point.hydropathy))),
7839
+ minimum_hydropathy: round3(minimumHydropathy),
7840
+ maximum_hydropathy: round3(maximumHydropathy),
7739
7841
  peaks: peaks.map((peak) => ({ ...peak, maximum: round3(peak.maximum), length: peak.end - peak.start + 1 })),
7740
7842
  points,
7741
7843
  };
@@ -8394,6 +8496,91 @@ window.__ModuleLoader__.load({
8394
8496
  return parts.join(' · ');
8395
8497
  }
8396
8498
 
8499
+ // ── figure cards (the same `tool.call.toolview` seat) ───────────────────────
8500
+
8501
+ /** The meta kind the sequence-logo and gRNA tools attach to their results. */
8502
+ const FIGURE_CARD_KIND = 'molbio-figure';
8503
+
8504
+ const CARD_SUMMARY_MAX = 300;
8505
+ const CARD_PATH_MAX = 500;
8506
+ const CARD_GUIDES_MAX = 5;
8507
+
8508
+ /** A bounded string, or '' when the value is not a string. */
8509
+ function cardText(value, max) {
8510
+ return typeof value === 'string' ? value.slice(0, max) : '';
8511
+ }
8512
+
8513
+ /** One guide row, validated field by field; malformed rows are dropped. */
8514
+ function cardGuide(row) {
8515
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return undefined;
8516
+ const sequence = cardText(row.sequence, 60);
8517
+ if (sequence === '') return undefined;
8518
+ return {
8519
+ rank: Number.isInteger(row.rank) ? row.rank : 0,
8520
+ sequence,
8521
+ pam: cardText(row.pam, 12),
8522
+ strand: row.strand === 'reverse' ? 'reverse' : 'forward',
8523
+ start: Number.isInteger(row.start) ? row.start : 0,
8524
+ end: Number.isInteger(row.end) ? row.end : 0,
8525
+ score: typeof row.score === 'number' && Number.isFinite(row.score) ? row.score : 0,
8526
+ offTargets: Number.isInteger(row.off_target_count) ? row.off_target_count : 0,
8527
+ };
8528
+ }
8529
+
8530
+ /**
8531
+ * Read a tool-result block's meta into what the figure card draws.
8532
+ *
8533
+ * Like `mapCardView`, the value is a projection this package produced itself,
8534
+ * but the card renders inside the conversation, so every field is checked and
8535
+ * bounded rather than trusted: a block from an older version, another tool, a
8536
+ * running call or a failed run must degrade to a notice, never throw.
8537
+ *
8538
+ * @param {unknown} meta the block's meta, as the client received it.
8539
+ * @returns {{kind: 'notice', message: string, ...} | {kind: 'figure', figure:
8540
+ * 'logo'|'grna', summary: string, svg: string, svgPath: string,
8541
+ * csvPath: string, mapPath: string, guides: Array}}
8542
+ */
8543
+ function figureCardView(meta) {
8544
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
8545
+ return { kind: 'notice', message: 'No figure data was attached to this call.' };
8546
+ }
8547
+ if (meta.kind !== FIGURE_CARD_KIND) {
8548
+ return { kind: 'notice', message: 'This call carries no figure.' };
8549
+ }
8550
+ const figure = meta.figure === 'grna' ? 'grna' : meta.figure === 'logo' ? 'logo' : '';
8551
+ if (figure === '') {
8552
+ return { kind: 'notice', message: 'This call carries an unknown figure type.' };
8553
+ }
8554
+ const base = {
8555
+ figure,
8556
+ summary: cardText(meta.summary, CARD_SUMMARY_MAX),
8557
+ svgPath: cardText(meta.svg_path, CARD_PATH_MAX),
8558
+ csvPath: cardText(meta.csv_path, CARD_PATH_MAX),
8559
+ mapPath: cardText(meta.map_path, CARD_PATH_MAX),
8560
+ guides: Array.isArray(meta.guides)
8561
+ // Bound the work before validating, then keep at most five VALID rows:
8562
+ // malformed entries must not consume the budget and hide good ones.
8563
+ ? meta.guides.slice(0, 50).map(cardGuide).filter((guide) => guide !== undefined).slice(0, CARD_GUIDES_MAX)
8564
+ : [],
8565
+ svg: '',
8566
+ };
8567
+ if (!isSvgMarkup(meta.svg)) {
8568
+ const message = meta.svg_missing === true
8569
+ ? 'No picture was requested for this call; the numbers are in the tool result.'
8570
+ : meta.svg_omitted === true
8571
+ ? `The figure is too large to draw inline (${Math.round((Number.isInteger(meta.svg_bytes) ? meta.svg_bytes : 0) / 1024)} KB); the written file is named below.`
8572
+ : 'The figure markup did not travel with this call.';
8573
+ return { kind: 'notice', message, ...base };
8574
+ }
8575
+ return { kind: 'figure', ...base, svg: meta.svg };
8576
+ }
8577
+
8578
+ /** The header line for a figure card, with a stable fallback. */
8579
+ function figureCardSummary(view) {
8580
+ if (view.summary !== '') return view.summary;
8581
+ return view.figure === 'logo' ? 'Sequence logo' : 'gRNA design';
8582
+ }
8583
+
8397
8584
  // ── literature library (papers.json) ────────────────────────────────────────
8398
8585
 
8399
8586
  /** The library file the molbio_paper_* tools write, relative to the workspace. */
@@ -8571,6 +8758,9 @@ window.__ModuleLoader__.load({
8571
8758
  exports.MAP_CARD_KIND = MAP_CARD_KIND;
8572
8759
  exports.mapCardView = mapCardView;
8573
8760
  exports.mapCardSummary = mapCardSummary;
8761
+ exports.FIGURE_CARD_KIND = FIGURE_CARD_KIND;
8762
+ exports.figureCardView = figureCardView;
8763
+ exports.figureCardSummary = figureCardSummary;
8574
8764
  exports.LIBRARY_FILE = LIBRARY_FILE;
8575
8765
  exports.readWorkspaceText = readWorkspaceText;
8576
8766
  exports.parseLibrary = parseLibrary;
@@ -8619,7 +8809,7 @@ window.__ModuleLoader__.load({
8619
8809
  * inside the factory would also have to be torn down by hand on unload.
8620
8810
  */
8621
8811
  const { createElement: h, useEffect, useMemo, useRef, useState } = __ext0;
8622
- const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
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");
8623
8813
  /** This implementation's identity, and the key its body/title register under. */
8624
8814
  const PANEL_ID = 'dsh-molbio-tools';
8625
8815
  /** Type discriminator the tab is opened by. */
@@ -8858,8 +9048,10 @@ window.__ModuleLoader__.load({
8858
9048
  return 'Papers';
8859
9049
  }
8860
9050
 
8861
- /** The tool names whose transcript card this package draws. */
9051
+ /** The tool names whose transcript card this package draws (plasmid maps). */
8862
9052
  const MAP_TOOL_KEYS = ['molbio_plasmid_map', 'molbio_plasmid_map_file'];
9053
+ /** Figure tools: a drawn logo / gRNA map plus (for gRNA) the ranked list. */
9054
+ const FIGURE_TOOL_KEYS = ['molbio_sequence_logo', 'molbio_grna_design'];
8863
9055
 
8864
9056
  /**
8865
9057
  * The transcript card for the map tools (`tool.call.toolview`, keyed by tool
@@ -8928,6 +9120,72 @@ window.__ModuleLoader__.load({
8928
9120
  return h('div', { style: styles.card }, ...content);
8929
9121
  }
8930
9122
 
9123
+ /**
9124
+ * The transcript card for the sequence-logo and gRNA tools. The logo card draws
9125
+ * the logo; the gRNA card draws the map when `map_path` was requested AND lists
9126
+ * the top-ranked guides (a gRNA call without a map still gets the table). Like
9127
+ * the map card it is a pure function of the block, and everything it reads is
9128
+ * validated and bounded by `figureCardView`.
9129
+ */
9130
+ function MolbioFigureCard({ block, inspect }) {
9131
+ const settled = block !== null && typeof block === 'object' && block.kind === 'tool-result';
9132
+ const view = figureCardView(settled ? block.meta : undefined);
9133
+ const host = useRef(null);
9134
+ const svg = view.kind === 'figure' ? view.svg : '';
9135
+ const [drawError, setDrawError] = useState(null);
9136
+
9137
+ useEffect(() => {
9138
+ const node = host.current;
9139
+ if (node === null) return;
9140
+ node.replaceChildren();
9141
+ if (svg === '') return;
9142
+ const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml');
9143
+ if (parsed.querySelector('parsererror') !== null) {
9144
+ setDrawError('the figure markup could not be parsed as SVG');
9145
+ return;
9146
+ }
9147
+ setDrawError(null);
9148
+ const element = parsed.documentElement;
9149
+ element.setAttribute('style', 'width:100%;height:auto;max-width:640px');
9150
+ node.append(document.importNode(element, true));
9151
+ }, [svg]);
9152
+
9153
+ const content = [h('div', { key: 'head', style: styles.cardHead }, settled ? figureCardSummary(view) : 'Drawing the figure…')];
9154
+ if (view.kind === 'figure') {
9155
+ if (drawError !== null) content.push(h('div', { key: 'drawerr', style: styles.cardNote }, drawError));
9156
+ content.push(h('div', { key: 'svg', ref: host, style: styles.cardSvg }));
9157
+ } else {
9158
+ content.push(h('div', { key: 'note', style: styles.cardNote }, view.message));
9159
+ }
9160
+ if (view.guides.length > 0) {
9161
+ const head = ['#', 'Guide', 'PAM', 'Position', 'Score', 'Off-targets'];
9162
+ const rows = view.guides.map((guide) => h('tr', { key: guide.rank },
9163
+ h('td', { style: styles.td }, String(guide.rank)),
9164
+ h('td', { style: styles.td }, guide.sequence),
9165
+ h('td', { style: styles.td }, guide.pam === '' ? '—' : guide.pam),
9166
+ h('td', { style: styles.td }, `${guide.strand === 'reverse' ? '−' : '+'}${guide.start}-${guide.end}`),
9167
+ h('td', { style: styles.td }, String(guide.score)),
9168
+ h('td', { style: styles.td }, String(guide.offTargets)),
9169
+ ));
9170
+ content.push(h('table', { key: 'guides', style: styles.table },
9171
+ h('thead', null, h('tr', null, ...head.map((label) => h('th', { key: label, style: styles.th }, label)))),
9172
+ h('tbody', null, ...rows)));
9173
+ }
9174
+ if (view.svgPath !== '') content.push(h('div', { key: 'path', style: styles.cardPath }, view.svgPath));
9175
+ if (view.csvPath !== '') content.push(h('div', { key: 'csv', style: styles.cardPath }, view.csvPath));
9176
+ if (settled && block.isError === true) {
9177
+ const text = (Array.isArray(block.content) ? block.content : [])
9178
+ .map((item) => (item !== null && typeof item === 'object' && item.type === 'text' ? String(item.text ?? '') : ''))
9179
+ .join('\n')
9180
+ .trim();
9181
+ content.push(h('div', { key: 'err', style: styles.error }, text === '' ? 'The call failed.' : text));
9182
+ if (typeof inspect === 'function') {
9183
+ content.push(h('button', { key: 'inspect', style: styles.cardButton, onClick: () => inspect() }, 'Inspect'));
9184
+ }
9185
+ }
9186
+ return h('div', { style: styles.card }, ...content);
9187
+ }
9188
+
8931
9189
  /**
8932
9190
  * The literature panel: the workspace `papers.json` the molbio_paper_* tools
8933
9191
  * maintain, rendered as a searchable reading list with a detail pane.
@@ -9276,6 +9534,15 @@ window.__ModuleLoader__.load({
9276
9534
  key: toolName,
9277
9535
  }, MolbioMapCard)), `molbio panel: ${toolName} card`);
9278
9536
  }
9537
+ // Figure cards: the sequence-logo and gRNA tools declare a `molbio-figure`
9538
+ // projection; the same component draws a logo or a gRNA map and, for gRNA,
9539
+ // lists the top-ranked guides even when no map was requested.
9540
+ for (const toolName of FIGURE_TOOL_KEYS) {
9541
+ ctx.effect(() => ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
9542
+ name: 'tool.call.toolview',
9543
+ key: toolName,
9544
+ }, MolbioFigureCard)), `molbio panel: ${toolName} card`);
9545
+ }
9279
9546
  // Plugins -> the installed plugin's detail page: the one place a user can
9280
9547
  // store the NCBI key without editing a YAML file or exporting a variable.
9281
9548
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-molbio-panel",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
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",
@@ -27,7 +27,8 @@
27
27
  "0.2.1-rc.2": "compatible",
28
28
  "0.2.1-rc.3": "compatible",
29
29
  "0.2.1-rc.4": "unknown",
30
- "0.2.1-rc.5": "compatible"
30
+ "0.2.1-rc.5": "compatible",
31
+ "0.2.2-rc.1": "compatible"
31
32
  }
32
33
  },
33
34
  "bundle": {